mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-14 07:32:05 +02:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2db7799b | ||
|
|
c596e25e5e | ||
|
|
74d1b5d6ce | ||
|
|
b9d4f76193 | ||
|
|
d4a963e216 | ||
|
|
be765d7bcc | ||
|
|
d43de3d95a | ||
|
|
bc0494fe0e | ||
|
|
c57a64d455 | ||
|
|
5bebd74b09 | ||
|
|
a6dd058a3d | ||
|
|
1fe8261fbd | ||
|
|
0e4c355e8b | ||
|
|
f6d926f4b9 | ||
|
|
a6be9dbaee | ||
|
|
67a628d67a | ||
|
|
0bf7eaed81 | ||
|
|
784a3d3a32 | ||
|
|
077bfa87b8 | ||
|
|
733f2e5929 | ||
|
|
0f994f89ac | ||
|
|
24f76398f9 | ||
|
|
14475915df | ||
|
|
a50e04f565 | ||
|
|
32fbd66912 | ||
|
|
3503a9da8e | ||
|
|
cef6abf5d0 | ||
|
|
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 | ||
|
|
44ae19ff64 | ||
|
|
23bac3cff5 | ||
|
|
cc05fec59d | ||
|
|
1206d5889d | ||
|
|
273e9c184d | ||
|
|
3f9baca85e | ||
|
|
0497a54928 | ||
|
|
5db2008fae |
@@ -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".
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: yaak-changelog
|
||||
description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
|
||||
---
|
||||
|
||||
# Yaak Changelog
|
||||
|
||||
Use this skill to create Yaak changelogs in the correct place:
|
||||
|
||||
- Beta or draft prerelease changelogs live only on the GitHub release.
|
||||
- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the target release.
|
||||
- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
|
||||
- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
|
||||
- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
|
||||
- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
|
||||
- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
|
||||
|
||||
2. Parse release bullets.
|
||||
- Treat each release-note bullet as one changelog entry.
|
||||
- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
|
||||
- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
|
||||
- Preserve the entry wording closely. Remove wrapping quotes from titles.
|
||||
- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
|
||||
- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
|
||||
|
||||
3. For beta or draft prerelease changelogs, update the GitHub release.
|
||||
- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
|
||||
- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
|
||||
- Prefer concise bullets with PR links and feedback links when available.
|
||||
- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
|
||||
- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
|
||||
- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
|
||||
- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
|
||||
- Stop after verifying the GitHub release body. The website checks below do not apply.
|
||||
|
||||
4. For stable website changelogs, create or edit the release directory.
|
||||
- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
|
||||
- For a new release, use today's date for `YYYYMMDD`.
|
||||
- For an existing release, keep the original directory date.
|
||||
- Do not create changelog directories for beta releases.
|
||||
|
||||
5. Write `_release.yaml`.
|
||||
- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
|
||||
- Keep minor items as quick entries without `content`.
|
||||
- Use `content` only when an entry needs its own markdown section.
|
||||
|
||||
```yaml
|
||||
title: "What's New in 2026.1.0"
|
||||
summary: "Brief overview of the most important additions and fixes"
|
||||
draft: true
|
||||
entries:
|
||||
- title: "Request debugging"
|
||||
category: feature
|
||||
pr: "https://github.com/mountain-loop/yaak/pull/123"
|
||||
feedback: "https://feedback.yaak.app/p/request-debugging"
|
||||
content: "request-debugging.md"
|
||||
- title: "Fix broken cookie clearing"
|
||||
category: fix
|
||||
pr: "https://github.com/mountain-loop/yaak/pull/124"
|
||||
```
|
||||
|
||||
6. Expand major entries.
|
||||
- Expand 3 to 6 major items when enough context exists.
|
||||
- Create slugified markdown files and reference them with `content`.
|
||||
- Read the related PR before writing expanded content.
|
||||
- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
|
||||
|
||||
7. Handle images.
|
||||
- Reuse screenshots from PRs when they exist.
|
||||
- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
|
||||
- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
|
||||
- If no real image is available, use a placeholder with real alt text and a caption.
|
||||
|
||||
8. Write `_intro.md`.
|
||||
- Add a short overview paragraph at the top of the release.
|
||||
- Focus on the major themes across the release instead of repeating every bullet.
|
||||
|
||||
9. Follow Yaak writing style.
|
||||
- Be direct and factual. Avoid hype.
|
||||
- State what changed and how to use it.
|
||||
- Keep paragraphs short.
|
||||
- Use backticks for code symbols, settings, and literal values.
|
||||
- Use bold sparingly for the most important phrase in a section.
|
||||
|
||||
## File Rules
|
||||
|
||||
- Beta releases must not create or edit files in `src/content/changelog/`.
|
||||
- Main files are `_release.yaml` and optional `_intro.md`.
|
||||
- Expanded entry files are regular markdown files such as `request-debugging.md`.
|
||||
- `entries[].content` must match an existing markdown filename in the same directory.
|
||||
- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
|
||||
|
||||
## Checks
|
||||
|
||||
- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
|
||||
- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
|
||||
- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
|
||||
- Ensure most entries include `pr` when the source release notes provide one.
|
||||
- For stable releases, ensure every referenced `content` file exists.
|
||||
- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Yaak Changelog"
|
||||
short_description: "Generate Yaak changelog releases"
|
||||
default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
## Submission
|
||||
|
||||
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
|
||||
|
||||
- [ ] This PR is a bug fix.
|
||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
- [ ] I tested this change locally.
|
||||
- [ ] I added or updated tests when reasonable.
|
||||
- [ ] I added screenshots or recordings for UI changes when reasonable.
|
||||
- [ ] I added or updated tests, or tests are not reasonable for this change.
|
||||
- [ ] I added screenshots or recordings, or this change does not affect the UI.
|
||||
|
||||
Explicit permission feedback item (required if not a bug fix):
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ const REVIEWER_LOGIN = "gschier";
|
||||
const LARGE_DIFF_CHANGED_FILES = 20;
|
||||
const LARGE_DIFF_CHANGED_LINES = 800;
|
||||
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
||||
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
|
||||
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
|
||||
const MIN_AUTOMATIC_PR_NUMBER = 494;
|
||||
|
||||
const LABELS = {
|
||||
inScope: {
|
||||
@@ -54,16 +53,25 @@ const MANAGED_LABEL_NAMES = [
|
||||
...new Set(Object.values(LABELS).map((label) => label.name)),
|
||||
];
|
||||
|
||||
// Each checkbox lists its current label first, followed by legacy labels still
|
||||
// accepted from PRs opened against older versions of the template.
|
||||
const CHECKBOXES = {
|
||||
bugFix: "This PR is a bug fix.",
|
||||
explicitPermission:
|
||||
bugFix: ["This PR is a bug fix."],
|
||||
explicitPermission: [
|
||||
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
||||
readContributing:
|
||||
],
|
||||
readContributing: [
|
||||
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
||||
testedLocally: "I tested this change locally.",
|
||||
testsUpdated: "I added or updated tests when reasonable.",
|
||||
screenshotsAdded:
|
||||
],
|
||||
testedLocally: ["I tested this change locally."],
|
||||
testsUpdated: [
|
||||
"I added or updated tests, or tests are not reasonable for this change.",
|
||||
"I added or updated tests when reasonable.",
|
||||
],
|
||||
screenshotsAdded: [
|
||||
"I added screenshots or recordings, or this change does not affect the UI.",
|
||||
"I added screenshots or recordings for UI changes when reasonable.",
|
||||
],
|
||||
};
|
||||
|
||||
function escapeRegExp(value) {
|
||||
@@ -103,8 +111,8 @@ function normalizeCheckboxLabel(label) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function checkboxState(body, label) {
|
||||
const expectedLabel = normalizeCheckboxLabel(label);
|
||||
function checkboxState(body, labels) {
|
||||
const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
||||
@@ -113,7 +121,7 @@ function checkboxState(body, label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
|
||||
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
|
||||
return match[1].toLowerCase() === "x";
|
||||
}
|
||||
}
|
||||
@@ -186,6 +194,18 @@ function analyzePullRequest(pr) {
|
||||
};
|
||||
}
|
||||
|
||||
if (labelNames.has(LABELS.explicitPermission.name)) {
|
||||
return {
|
||||
blockers: [],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.explicitPermission.name],
|
||||
largeDiff,
|
||||
status: "in_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (!templateUsed) {
|
||||
blockers.push({
|
||||
label: LABELS.missingTemplate.name,
|
||||
@@ -244,7 +264,8 @@ function analyzePullRequest(pr) {
|
||||
if (states.testsUpdated !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message: "Confirm that tests were added or updated when reasonable.",
|
||||
message:
|
||||
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +273,7 @@ function analyzePullRequest(pr) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
|
||||
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -428,10 +449,6 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
||||
};
|
||||
}
|
||||
|
||||
function wasCreatedBefore(value, cutoff) {
|
||||
return Date.parse(value) < Date.parse(cutoff);
|
||||
}
|
||||
|
||||
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
||||
return true;
|
||||
@@ -603,7 +620,7 @@ async function checkPullRequest({
|
||||
repo,
|
||||
pullNumber,
|
||||
dryRun,
|
||||
skipCreatedBefore,
|
||||
minimumAutomaticPullNumber,
|
||||
}) {
|
||||
const response = await github.rest.pulls.get({
|
||||
owner,
|
||||
@@ -613,12 +630,9 @@ async function checkPullRequest({
|
||||
const pr = response.data;
|
||||
const issueNumber = pr.number;
|
||||
|
||||
if (
|
||||
skipCreatedBefore != null &&
|
||||
wasCreatedBefore(pr.created_at, skipCreatedBefore)
|
||||
) {
|
||||
if (pr.user.type === "Bot") {
|
||||
core.notice(
|
||||
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
|
||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
@@ -626,7 +640,26 @@ async function checkPullRequest({
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
|
||||
skipReason: `bot @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
minimumAutomaticPullNumber != null &&
|
||||
pr.number < minimumAutomaticPullNumber
|
||||
) {
|
||||
core.notice(
|
||||
`Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
@@ -756,8 +789,8 @@ async function run({ github, context, core }) {
|
||||
context.eventName === "workflow_dispatch" &&
|
||||
dryRunInput !== false &&
|
||||
dryRunInput !== "false";
|
||||
const skipCreatedBefore =
|
||||
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
|
||||
const minimumAutomaticPullNumber =
|
||||
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
|
||||
let pullNumbers;
|
||||
|
||||
if (payloadPr != null) {
|
||||
@@ -795,7 +828,7 @@ async function run({ github, context, core }) {
|
||||
repo,
|
||||
pullNumber: pr.number,
|
||||
dryRun,
|
||||
skipCreatedBefore,
|
||||
minimumAutomaticPullNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ jobs:
|
||||
- name: Run JS Tests
|
||||
run: vp test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
run: cargo test --all --features yaak-app-client/wry
|
||||
|
||||
@@ -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,13 +30,14 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Check contribution policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout policy script
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha || github.ref }}
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check contribution policy
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-flathub:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Update Flathub manifest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -34,7 +35,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- name: Install source generators
|
||||
run: |
|
||||
|
||||
@@ -15,6 +15,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Publish @yaakapp/api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -14,35 +15,59 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel-based Macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: ""
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "cef"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
runtime: "cef"
|
||||
targets: ""
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
# Windows ARM64
|
||||
- platform: "windows-latest"
|
||||
args: "--target aarch64-pc-windows-msvc"
|
||||
args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
@@ -66,11 +91,18 @@ jobs:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Cache CEF (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/tauri-cef
|
||||
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
@@ -98,7 +130,7 @@ jobs:
|
||||
- name: Run JS Tests
|
||||
run: vp test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --exclude yaak-cli
|
||||
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version
|
||||
@@ -150,13 +182,30 @@ jobs:
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
||||
releaseBody: "<!-- generated-by-yaak-releases -->"
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
- name: Build and upload CEF tarball from deb (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
|
||||
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
|
||||
rm -rf "$extract_dir"
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -173,7 +222,7 @@ jobs:
|
||||
run: |
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
Push-Location crates-tauri/yaak-app-client
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Release CLI to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [yaak-cli-*]
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -15,6 +15,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
prepare-vendored-assets:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Prepare vendored plugin assets
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -118,7 +119,7 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "Building yaak version: $VERSION"
|
||||
@@ -175,7 +176,7 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
|
||||
@@ -7,6 +7,7 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
|
||||
@@ -58,3 +58,4 @@ flatpak/node-sources.json
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
|
||||
- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
|
||||
- Do not commit, push, or tag without explicit approval
|
||||
|
||||
Generated
+1048
-126
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -31,6 +31,7 @@ members = [
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
@@ -47,9 +48,13 @@ schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tauri = "2.11.1"
|
||||
tauri = { version = "2.11.1", default-features = false, features = [
|
||||
"common-controls-v6",
|
||||
"compression",
|
||||
"dynamic-acl",
|
||||
] }
|
||||
tauri-plugin = "2.6.1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-dialog = { version = "2.7.1", default-features = false }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
@@ -84,8 +89,13 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
[profile.release]
|
||||
strip = false
|
||||
|
||||
[patch.crates-io]
|
||||
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||
|
||||
@@ -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 -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- 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>
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
||||
export function ContextMenus() {
|
||||
const menus = useAtomValue(contextMenusAtom);
|
||||
return (
|
||||
<>
|
||||
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
||||
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||
<ContextMenu
|
||||
items={items}
|
||||
triggerPosition={triggerPosition}
|
||||
triggerRect={triggerRect}
|
||||
// The trigger isn't a React component here, so it arrives as an element and gets
|
||||
// wrapped to look like the ref the menu expects
|
||||
triggerRef={{ current: triggerEl ?? null }}
|
||||
onClose={() => hideContextMenu(id)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { hideToastById, showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Input } from "./core/Input";
|
||||
|
||||
interface Props {
|
||||
feature: FeedbackFeature;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackToast({ feature, onDone }: Props) {
|
||||
const [text, setText] = useState<string>("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const sentRef = useRef(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDone();
|
||||
hideToastById(`feature-feedback-${feature}`);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmedText = text.trim();
|
||||
if (sentRef.current || trimmedText.length === 0) return;
|
||||
|
||||
sentRef.current = true;
|
||||
setSent(true);
|
||||
onDone();
|
||||
|
||||
// Fire-and-forget; failures are intentionally ignored
|
||||
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: 3000,
|
||||
color: "success",
|
||||
message: "Thanks for the feedback!",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
||||
<div className="h-20">
|
||||
<Input
|
||||
size="xs"
|
||||
// The editor forces its mono font on the scroller, so the override
|
||||
// has to target it directly
|
||||
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
||||
label="Feedback"
|
||||
hideLabel
|
||||
stateKey={null}
|
||||
multiLine
|
||||
fullHeight
|
||||
placeholder="Your thoughts..."
|
||||
onChange={setText}
|
||||
/>
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="end">
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={sent || text.trim().length === 0}
|
||||
onClick={handleSend}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
||||
import {
|
||||
type GrpcRequest,
|
||||
type HttpRequestHeader,
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
|
||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||
|
||||
const handleChangeUrl = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleChangeMessage = useCallback(
|
||||
// Not debounced: handleSend reads message from the store, so a pending
|
||||
// debounced patch would send stale text
|
||||
(message: string) => patchModel(activeRequest, { message }),
|
||||
[activeRequest],
|
||||
);
|
||||
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(description: string) => patchModel(activeRequest, { description }),
|
||||
(description: string) => patchModelDebounced(activeRequest, { description }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -299,7 +306,7 @@ export function GrpcRequestPane({
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { acceptLanguages } from "../lib/data/acceptLanguages";
|
||||
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
||||
import { charsets } from "../lib/data/charsets";
|
||||
import { connections } from "../lib/data/connections";
|
||||
import { encodings } from "../lib/data/encodings";
|
||||
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
||||
import { headerNames } from "../lib/data/headerNames";
|
||||
import { mimeTypes } from "../lib/data/mimetypes";
|
||||
import { userAgents } from "../lib/data/userAgents";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairEditorRow } from "./core/PairEditor";
|
||||
@@ -105,12 +108,21 @@ export function HeadersEditor({
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, string[]> = {
|
||||
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||
"content-type": mimeTypes,
|
||||
accept: ["*/*", ...mimeTypes],
|
||||
"accept-encoding": encodings,
|
||||
"content-encoding": encodings,
|
||||
connection: connections,
|
||||
"accept-charset": charsets,
|
||||
"accept-language": acceptLanguages,
|
||||
"cache-control": cacheControlDirectives,
|
||||
"user-agent": userAgents,
|
||||
pragma: ["no-cache"],
|
||||
te: ["trailers", "compress", "deflate", "gzip"],
|
||||
dnt: ["1", "0"],
|
||||
"upgrade-insecure-requests": ["1"],
|
||||
"x-requested-with": ["XMLHttpRequest"],
|
||||
};
|
||||
|
||||
const valueType = (pair: Pair): InputProps["type"] => {
|
||||
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
const name = headerName.toLowerCase().trim();
|
||||
const options: GenericCompletionOption[] =
|
||||
headerOptionsMap[name]?.map((o) => ({
|
||||
label: o,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
})) ?? [];
|
||||
const options: GenericCompletion[] =
|
||||
headerOptionsMap[name]?.map((o) =>
|
||||
typeof o === "string"
|
||||
? {
|
||||
label: o,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
// Show the short label but insert the full value (e.g. User-Agent)
|
||||
label: o.label,
|
||||
apply: o.value,
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -16,10 +15,10 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -38,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -75,15 +74,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
const requests = get(allRequestsAtom);
|
||||
return requests
|
||||
.filter((r) => r.id !== activeRequestId)
|
||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||
});
|
||||
|
||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -131,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("http_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
@@ -195,7 +204,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
const patch: Partial<HttpRequest> = {
|
||||
bodyType,
|
||||
body: convertRequestBody({
|
||||
body: activeRequest.body,
|
||||
fromBodyType: activeRequest.bodyType,
|
||||
toBodyType: bodyType,
|
||||
}),
|
||||
};
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
@@ -265,28 +281,19 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
const { mutate: importCurl } = useImportCurl();
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
@@ -323,7 +330,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -369,7 +376,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
@@ -377,7 +384,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
@@ -429,7 +436,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModel(activeRequest, { body })}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === "string" ? (
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
/** The whole value, exactly as it reads in the document */
|
||||
text: string;
|
||||
sniffed: SniffedValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
||||
*
|
||||
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
||||
* explicit click, behind a spinner, rather than during layout.
|
||||
*/
|
||||
export function LargeValueDialog({ text, sniffed }: Props) {
|
||||
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
||||
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
||||
|
||||
// Nothing recognised it, so there is nothing to decode it into
|
||||
if (sniffed == null || viewer == null) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
{sniffed != null && (
|
||||
<Banner color="info" className="mb-3">
|
||||
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
||||
response.
|
||||
</Banner>
|
||||
)}
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded == null) {
|
||||
return (
|
||||
<HStack className="h-full" alignItems="center" justifyContent="center">
|
||||
<LoadingIcon />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if ("error" in decoded) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
<Banner color="danger" className="mb-3">
|
||||
Failed to decode this {sniffed.label} value: {decoded.error}
|
||||
</Banner>
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
const { bytes } = decoded;
|
||||
|
||||
switch (viewer) {
|
||||
case "svg":
|
||||
return <DecodedSvg bytes={bytes} />;
|
||||
case "image":
|
||||
return (
|
||||
<div className="h-full overflow-auto flex items-center justify-center">
|
||||
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
||||
</div>
|
||||
);
|
||||
case "audio":
|
||||
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "video":
|
||||
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "pdf":
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes} />
|
||||
</Suspense>
|
||||
);
|
||||
case "text":
|
||||
return <DecodedText bytes={bytes} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
||||
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
||||
*/
|
||||
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <SvgViewer text={text} />;
|
||||
}
|
||||
|
||||
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <PagedText text={text} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
||||
*
|
||||
* It lives in the dialog's title rather than above the content, which would cost a band of
|
||||
* whitespace the width of the dialog to hold one small button.
|
||||
*/
|
||||
function LargeValueActions({ text, sniffed }: Props) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
largeValueActions({
|
||||
value: () => text,
|
||||
sniffed,
|
||||
// The tag copies only what it hides; in here, what you see is the whole value
|
||||
copyText: () => text,
|
||||
}),
|
||||
[text, sniffed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button size="xs" variant="border" forDropdown>
|
||||
Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
||||
|
||||
/** Which viewer a media type calls for, or null if none of them can show it. */
|
||||
function viewerFor(mime: string): Viewer | null {
|
||||
if (mime.startsWith("image/svg")) return "svg";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
type Decoded = { bytes: Uint8Array } | { error: string };
|
||||
|
||||
/**
|
||||
* The value's bytes, once they exist.
|
||||
*
|
||||
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
||||
* during render — the dialog paints with a spinner first, instead of opening late.
|
||||
*/
|
||||
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
||||
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sniffed == null) return;
|
||||
|
||||
setDecoded(null);
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
let result: Decoded;
|
||||
try {
|
||||
result = { bytes: decodeValue(text, sniffed) };
|
||||
} catch (err) {
|
||||
result = { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!cancelled) setDecoded(result);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [text, sniffed]);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.slice().buffer;
|
||||
}
|
||||
|
||||
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
||||
const PAGE_CHARS = 50_000;
|
||||
|
||||
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
||||
const WRAP_CHARS = 120;
|
||||
|
||||
/**
|
||||
* The raw text, in pages of short lines.
|
||||
*
|
||||
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
||||
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
||||
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
||||
*/
|
||||
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
||||
const from = page * PAGE_CHARS;
|
||||
const to = Math.min(from + PAGE_CHARS, text.length);
|
||||
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
||||
<div>{children}</div>
|
||||
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
||||
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
||||
<span>
|
||||
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
||||
</span>
|
||||
{pages > 1 && (
|
||||
<HStack space={2} alignItems="center" className="ml-auto">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
Page {page + 1} of {pages.toLocaleString()}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page >= pages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
||||
function wrap(text: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.length <= WRAP_CHARS) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
||||
lines.push(line.slice(i, i + WRAP_CHARS));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function showLargeValueDialog({ text, sniffed }: Props) {
|
||||
showDialog({
|
||||
id: "large-value",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-10rem)]",
|
||||
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
||||
// separate description and a row of its own for the button cost three bands to say this.
|
||||
title: (
|
||||
<HStack space={3} alignItems="center">
|
||||
<span>
|
||||
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
||||
</span>
|
||||
<LargeValueActions text={text} sniffed={sniffed} />
|
||||
</HStack>
|
||||
),
|
||||
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
||||
});
|
||||
}
|
||||
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedResponseId(r.id),
|
||||
onSelect: () => {
|
||||
onPinnedResponseId(r.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,11 +112,22 @@ export function SettingsGeneral() {
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
<CargoFeature feature="license">
|
||||
<SettingsSection title="Feedback">
|
||||
<SettingRowBoolean
|
||||
title="Prompt for feedback"
|
||||
description="Show rare one-time prompts asking how new features are working."
|
||||
checked={settings.promptFeedback}
|
||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
{showWorkspaceSettingsMovedBanner && (
|
||||
<DismissibleBanner
|
||||
id="workspace-settings-moved-2026-06-30"
|
||||
color="info"
|
||||
className="p-4 max-w-xl mx-auto"
|
||||
className="w-full p-4 max-w-xl mr-auto"
|
||||
>
|
||||
<p>
|
||||
Workspace specific settings have moved to{" "}
|
||||
|
||||
@@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||
const filterText = useAtomValue(sidebarFilterAtom);
|
||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const filterRef = useRef<InputHandle>(null);
|
||||
@@ -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({
|
||||
items,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
pairs: HttpRequest["headers"];
|
||||
pairs: EditablePair[];
|
||||
stateKey: PairEditorProps["stateKey"];
|
||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
||||
onChange: PairEditorProps["onChange"];
|
||||
};
|
||||
|
||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import {
|
||||
flushAllModelWrites,
|
||||
getModel,
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||
import classNames from "classnames";
|
||||
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -18,17 +22,15 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -54,15 +56,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
const requests = get(allRequestsAtom);
|
||||
return requests
|
||||
.filter((r) => r.id !== activeRequestId)
|
||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||
});
|
||||
|
||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -83,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
[],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("websocket_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
@@ -127,23 +139,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||
await connectWebsocket({
|
||||
requestId: activeRequest.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
@@ -153,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (connection == null) return;
|
||||
await flushAllModelWrites(); // The backend reads the message from the DB
|
||||
await sendWebsocket({
|
||||
connectionId: connection?.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
@@ -165,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
}, [connection]);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -249,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
@@ -257,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_MESSAGE}>
|
||||
@@ -269,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={activeRequest.message}
|
||||
language={messageLanguage}
|
||||
onChange={(message) => patchModel(activeRequest, { message })}
|
||||
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
@@ -286,7 +291,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
@@ -294,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
@@ -233,23 +233,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
|
||||
export interface ContextMenuProps {
|
||||
triggerPosition: { x: number; y: number } | null;
|
||||
/**
|
||||
* The box the menu belongs to, when the trigger has one.
|
||||
*
|
||||
* Placement aligns to the trigger's left or right edge depending on the room beside it, so a
|
||||
* menu opened from an element wants its real rect. Without one the position is treated as a
|
||||
* zero-width point, which is right for a menu opened at the cursor and wrong for one opened
|
||||
* from a button.
|
||||
*/
|
||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||
/**
|
||||
* The element the menu belongs to, so a click on it doesn't count as a click outside.
|
||||
*
|
||||
* Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
|
||||
* then the trigger's own click opens it again, and pressing it looks like it does nothing.
|
||||
*/
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
className?: string;
|
||||
items: DropdownProps["items"];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||
{ triggerPosition, className, items, onClose },
|
||||
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
|
||||
ref,
|
||||
) {
|
||||
const triggerShape = useMemo(
|
||||
() => ({
|
||||
top: triggerPosition?.y ?? 0,
|
||||
bottom: triggerPosition?.y ?? 0,
|
||||
left: triggerPosition?.x ?? 0,
|
||||
right: triggerPosition?.x ?? 0,
|
||||
}),
|
||||
[triggerPosition],
|
||||
() =>
|
||||
triggerRect ?? {
|
||||
top: triggerPosition?.y ?? 0,
|
||||
bottom: triggerPosition?.y ?? 0,
|
||||
left: triggerPosition?.x ?? 0,
|
||||
right: triggerPosition?.x ?? 0,
|
||||
},
|
||||
[triggerPosition, triggerRect],
|
||||
);
|
||||
|
||||
if (triggerPosition == null) return null;
|
||||
@@ -259,9 +276,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
||||
isOpen={true} // Always open because we return null if not
|
||||
className={className}
|
||||
defaultSelectedIndex={null}
|
||||
// A menu opened from an element points back at it. One opened at the cursor has nothing
|
||||
// to point at, so it goes without.
|
||||
showTriangle={triggerRect != null}
|
||||
ref={ref}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
triggerRef={triggerRef}
|
||||
triggerShape={triggerShape}
|
||||
/>
|
||||
);
|
||||
@@ -277,7 +298,9 @@ interface MenuProps {
|
||||
fullWidth?: boolean;
|
||||
isOpen: boolean;
|
||||
items: DropdownItem[];
|
||||
triggerRef?: RefObject<HTMLButtonElement | null>;
|
||||
// Any element, not just a button: a menu can be opened from anything, and this is only ever
|
||||
// used to ask whether a click landed on the trigger
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
isSubmenu?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,24 @@
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
|
||||
only the affordance that says so. */
|
||||
.tag-action {
|
||||
@apply inline-flex items-center align-middle ml-1;
|
||||
@apply text-text-subtlest;
|
||||
|
||||
svg {
|
||||
@apply w-[0.9em] h-[0.9em];
|
||||
}
|
||||
}
|
||||
|
||||
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
|
||||
never changes the line's height. */
|
||||
.tag-thumbnail {
|
||||
@apply inline-block align-middle mr-1 rounded-xs;
|
||||
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
|
||||
}
|
||||
|
||||
.fn {
|
||||
@apply inline-block;
|
||||
.fn-inner {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs";
|
||||
import { vim } from "@replit/codemirror-vim";
|
||||
|
||||
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import type { EditorKeymap } from "@yaakapp-internal/models";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { md5 } from "js-md5";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import {
|
||||
Children,
|
||||
@@ -33,6 +33,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"
|
||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||
import { docFingerprint } from "../../../lib/docFingerprint";
|
||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||
import { jotaiStore } from "../../../lib/jotai";
|
||||
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
|
||||
import "./Editor.css";
|
||||
import {
|
||||
baseExtensions,
|
||||
editableExtensions,
|
||||
getLanguageExtension,
|
||||
multiLineExtensions,
|
||||
readonlyExtensions,
|
||||
@@ -288,7 +290,7 @@ function EditorInner({
|
||||
function configureReadOnly() {
|
||||
if (cm.current === null) return;
|
||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
const next = readOnly ? readonlyExtensions : emptyExtension;
|
||||
const next = readOnly ? readonlyExtensions : editableExtensions;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (current === next) return;
|
||||
|
||||
@@ -381,6 +383,7 @@ function EditorInner({
|
||||
const initEditorRef = useCallback(
|
||||
function initEditorRef(container: HTMLDivElement | null) {
|
||||
if (container === null) {
|
||||
flushCachedEditorState(stateKey);
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
@@ -410,7 +413,7 @@ function EditorInner({
|
||||
keymapCompartment.current.of(
|
||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||
),
|
||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
|
||||
...getExtensions({
|
||||
container,
|
||||
singleLine,
|
||||
@@ -430,7 +433,7 @@ function EditorInner({
|
||||
: []),
|
||||
];
|
||||
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
|
||||
|
||||
const doc = `${defaultValue ?? ""}`;
|
||||
const config: EditorStateConfig = { extensions, doc };
|
||||
@@ -639,7 +642,7 @@ function getExtensions({
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
|
||||
saveCachedEditorState(stateKey, update.state);
|
||||
saveCachedEditorStateDebounced(stateKey, update.state);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -652,13 +655,53 @@ const placeholderElFromText = (text: string | undefined) => {
|
||||
return el;
|
||||
};
|
||||
|
||||
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
|
||||
// so debounce it per state key and flush when the editor unmounts.
|
||||
//
|
||||
// The cost scales with the document, and every update pays it in full:
|
||||
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
|
||||
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
|
||||
// only samples read-only ones, so editing a large body still hashes all of it
|
||||
// - `sessionStorage.setItem` is synchronous and blocks the main thread
|
||||
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
|
||||
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
|
||||
// half once documents get into the megabytes.
|
||||
const SAVE_STATE_DEBOUNCE_MS = 500;
|
||||
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
|
||||
|
||||
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
|
||||
if (!stateKey) return;
|
||||
let saver = stateSavers.get(stateKey);
|
||||
if (saver == null) {
|
||||
saver = debounce(
|
||||
(s: EditorState) => saveCachedEditorState(stateKey, s),
|
||||
SAVE_STATE_DEBOUNCE_MS,
|
||||
);
|
||||
stateSavers.set(stateKey, saver);
|
||||
}
|
||||
saver(state);
|
||||
}
|
||||
|
||||
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
|
||||
// for every state key the session has ever shown. A pending saver holds the last EditorState,
|
||||
// which holds the whole document.
|
||||
function flushCachedEditorState(stateKey: string | null) {
|
||||
if (!stateKey) return;
|
||||
const saver = stateSavers.get(stateKey);
|
||||
if (saver == null) return;
|
||||
saver.flush();
|
||||
stateSavers.delete(stateKey);
|
||||
}
|
||||
|
||||
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||
if (!stateKey || state == null) return;
|
||||
const stateObj = state.toJSON(stateFields);
|
||||
|
||||
// Save state in sessionStorage by removing doc and saving the hash of it instead.
|
||||
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
|
||||
// This will be checked on restore and put back in if it matches.
|
||||
stateObj.docHash = md5(stateObj.doc);
|
||||
// Editable documents get the exact hash, so a collision can never restore undo history
|
||||
// belonging to other content
|
||||
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
@@ -668,7 +711,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
try {
|
||||
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
const { docHash, ...state } = JSON.parse(stateStr);
|
||||
|
||||
// Ensure the doc matches the one that was used to save the state
|
||||
if (docHash !== md5(doc)) {
|
||||
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
|
||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||
import type { EditorProps } from "./Editor";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
import { largeValues, mediaValues } from "./largeValues";
|
||||
import { pairs } from "./pairs/extension";
|
||||
import { searchMatchCount } from "./searchMatchCount";
|
||||
import { text } from "./text/extension";
|
||||
@@ -258,8 +259,19 @@ export const baseExtensions = [
|
||||
export const readonlyExtensions = [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
||||
// Nobody is editing this, so every rule applies
|
||||
largeValues,
|
||||
];
|
||||
|
||||
/**
|
||||
* The counterpart for a document being edited.
|
||||
*
|
||||
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
|
||||
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
|
||||
* hide text by length alone stay out. See {@link mediaValues}.
|
||||
*/
|
||||
export const editableExtensions = [mediaValues];
|
||||
|
||||
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||
search({ top: true }),
|
||||
searchMatchCount(),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { defaultBoost } from "./twig/completion";
|
||||
|
||||
export type GenericCompletion = GenericCompletionOption & {
|
||||
apply?: Completion["apply"];
|
||||
};
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletionOption[];
|
||||
options: GenericCompletion[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { text } from "./text/extension";
|
||||
import { twig } from "./twig/extension";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
COLLAPSE_MEDIA_CHARS,
|
||||
COLLAPSE_TOKEN_CHARS,
|
||||
collapseDecorations,
|
||||
largeValues,
|
||||
MAX_VISIBLE_LINE_CHARS,
|
||||
} from "./largeValues";
|
||||
import type { SniffedValue } from "./sniffValue";
|
||||
|
||||
const BIG = "A".repeat(1_000_000);
|
||||
|
||||
/** With a grammar, so tokens can be collapsed individually */
|
||||
const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
|
||||
|
||||
/** Without a grammar, so only the column rule applies */
|
||||
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
|
||||
|
||||
/**
|
||||
* The decorations as if the whole document were on screen.
|
||||
*
|
||||
* In the editor the plugin passes the viewport instead, which is the same call with narrower
|
||||
* ranges — the rules themselves don't know the difference.
|
||||
*/
|
||||
function decorationsFor(state: EditorState) {
|
||||
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||
}
|
||||
|
||||
function collapsedRanges(state: EditorState) {
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
const iter = decorationsFor(state).iter();
|
||||
while (iter.value != null) {
|
||||
ranges.push({ from: iter.from, to: iter.to });
|
||||
iter.next();
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** How much of each line is still rendered */
|
||||
function visibleLineLengths(state: EditorState) {
|
||||
const hidden = collapsedRanges(state);
|
||||
const lengths: number[] = [];
|
||||
for (let n = 1; n <= state.doc.lines; n++) {
|
||||
const line = state.doc.line(n);
|
||||
const covered = hidden
|
||||
.filter((h) => h.from >= line.from && h.to <= line.to)
|
||||
.reduce((sum, h) => sum + (h.to - h.from), 0);
|
||||
lengths.push(line.length - covered);
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
describe("collapsing", () => {
|
||||
test("leaves an ordinary body alone", () => {
|
||||
expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves a large body of short lines alone", () => {
|
||||
const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
|
||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves a line just under the column limit alone", () => {
|
||||
expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
|
||||
});
|
||||
|
||||
test("never renders more than the column limit per line", () => {
|
||||
for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
|
||||
for (const length of visibleLineLengths(state)) {
|
||||
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the document text intact", () => {
|
||||
const doc = `{"image":"${BIG}"}`;
|
||||
expect(jsonState(doc).sliceDoc()).toBe(doc);
|
||||
expect(jsonState(doc).doc.length).toBe(doc.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token collapsing, with a grammar", () => {
|
||||
test("hides the whole value, leaving its quotes visible", () => {
|
||||
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
|
||||
const state = jsonState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||
expect(doc[ranges[0]!.from - 1]).toBe('"');
|
||||
expect(doc[ranges[0]!.to]).toBe('"');
|
||||
|
||||
// Everything after the value is still rendered, unlike a plain column cut
|
||||
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
|
||||
});
|
||||
|
||||
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
|
||||
// machine. With a bigger body this falls back to the column cut, which is by design but
|
||||
// makes the assertion depend on how fast the runner is.
|
||||
test("keeps every key visible in a minified body with several large values", () => {
|
||||
const chunk = "B".repeat(20_000);
|
||||
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
|
||||
const state = jsonState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(5);
|
||||
for (const key of ["a", "b", "c", "d", "e"]) {
|
||||
// No collapse swallows the key
|
||||
const at = doc.indexOf(`"${key}":`);
|
||||
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
|
||||
}
|
||||
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||
});
|
||||
|
||||
test("collapses a value on a pretty-printed line", () => {
|
||||
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
|
||||
const state = jsonState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||
// Only the long line is touched, and all that is left of it is ` "image": ""`
|
||||
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
|
||||
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
|
||||
});
|
||||
|
||||
test("ignores tokens under the collapse threshold", () => {
|
||||
// Under the threshold once the surrounding quotes are counted
|
||||
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
|
||||
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
|
||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||
|
||||
// Nothing is big enough to collapse on its own, so the column rule takes over
|
||||
const ranges = collapsedRanges(jsonState(doc));
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.to).toBe(doc.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("column collapsing, without a grammar", () => {
|
||||
test("collapses everything past the limit", () => {
|
||||
const ranges = collapsedRanges(plainState(BIG));
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(ranges[0]!.to).toBe(BIG.length);
|
||||
});
|
||||
|
||||
test("handles a long line of many short tokens", () => {
|
||||
// A single-line CSV row: no token is long enough to collapse on its own
|
||||
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
|
||||
const ranges = collapsedRanges(plainState(row));
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
});
|
||||
|
||||
test("collapses each long line independently", () => {
|
||||
const doc = `${BIG}\nshort\n${BIG}`;
|
||||
const ranges = collapsedRanges(plainState(doc));
|
||||
expect(ranges).toHaveLength(2);
|
||||
for (const length of visibleLineLengths(plainState(doc))) {
|
||||
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||
}
|
||||
});
|
||||
|
||||
test("never hides a line break", () => {
|
||||
const doc = `${BIG}\nshort`;
|
||||
const state = plainState(doc);
|
||||
for (const { from, to } of collapsedRanges(state)) {
|
||||
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
||||
}
|
||||
expect(state.doc.lines).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undelimited tokens", () => {
|
||||
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
|
||||
// line with nothing on it but a tag, so the column cut handles it instead.
|
||||
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
|
||||
|
||||
test("falls back to the column cut for a long line of plain text", () => {
|
||||
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
|
||||
const ranges = collapsedRanges(textState(doc));
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(ranges[0]!.to).toBe(doc.length);
|
||||
});
|
||||
|
||||
test("leaves the start of the line readable", () => {
|
||||
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
|
||||
const state = textState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sniffing the collapsed value", () => {
|
||||
/** The widget standing in for each collapse, in document order */
|
||||
function widgets(state: EditorState) {
|
||||
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
|
||||
const iter = decorationsFor(state).iter();
|
||||
while (iter.value != null) {
|
||||
found.push(iter.value.spec.widget);
|
||||
iter.next();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A base64 value that starts with a PNG signature and runs on well past the limits */
|
||||
function pngBase64(length = 1_000_000) {
|
||||
const bytes = new Uint8Array(length);
|
||||
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
test("names the format of a base64 value inside JSON", () => {
|
||||
const state = jsonState(`{"image":"${pngBase64()}"}`);
|
||||
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||
});
|
||||
|
||||
test("names the format of a data URI", () => {
|
||||
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
|
||||
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
|
||||
});
|
||||
|
||||
test("names nothing when the value is just text", () => {
|
||||
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
|
||||
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
|
||||
});
|
||||
|
||||
test("reads a column cut from the start of its line, not from the cut", () => {
|
||||
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
|
||||
// belongs to begins at the start of the line, which is where the signature is
|
||||
const state = plainState(pngBase64());
|
||||
const [widget] = widgets(state);
|
||||
|
||||
expect(widget?.valueFrom).toBe(0);
|
||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||
});
|
||||
|
||||
test("reads a token collapse from the value itself", () => {
|
||||
const doc = `{"image":"${pngBase64()}"}`;
|
||||
const [widget] = widgets(jsonState(doc));
|
||||
|
||||
// Just past the opening quote, so the signature is the first thing it sees
|
||||
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
|
||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||
});
|
||||
|
||||
test("finds a run that starts partway through a line, and names it", () => {
|
||||
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
|
||||
// so the prefix stays on screen and the blob is still named.
|
||||
const prefix = "some prefix text ";
|
||||
const state = plainState(`${prefix}${pngBase64()}`);
|
||||
const [widget] = widgets(state);
|
||||
|
||||
expect(widget?.valueFrom).toBe(prefix.length);
|
||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputing", () => {
|
||||
test("updates when the document changes", () => {
|
||||
const state = plainState('{"image":"short"}');
|
||||
expect(collapsedRanges(state)).toEqual([]);
|
||||
|
||||
const next = state.update({
|
||||
changes: { from: 0, to: state.doc.length, insert: BIG },
|
||||
}).state;
|
||||
expect(collapsedRanges(next)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media collapsing, below the length rules", () => {
|
||||
/** A real PNG signature, at a size that no length rule would touch */
|
||||
function png(bytes: number) {
|
||||
const b = new Uint8Array(bytes);
|
||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
let binary = "";
|
||||
for (const byte of b) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Well under every length threshold, so only the media rule can collapse it */
|
||||
const SMALL = png(3_000);
|
||||
|
||||
test("collapses a small image on a short line", () => {
|
||||
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
|
||||
|
||||
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
|
||||
});
|
||||
|
||||
test("names it, so the tag can say what it is", () => {
|
||||
const state = jsonState(`{"avatar":"${SMALL}"}`);
|
||||
const iter = decorationsFor(state).iter();
|
||||
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||
});
|
||||
|
||||
test("leaves a value of the same size alone when nothing recognises it", () => {
|
||||
// The only difference from the case above is what the bytes turn out to be
|
||||
const prose = "x".repeat(SMALL.length);
|
||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves anything under the media threshold alone, recognised or not", () => {
|
||||
const tiny = png(400);
|
||||
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
|
||||
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves text alone even when its first bytes match a signature", () => {
|
||||
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
|
||||
// enough that the sniff alone must not be allowed to hide something.
|
||||
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
|
||||
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
|
||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("still collapses it once it is long enough to be a rendering problem", () => {
|
||||
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
|
||||
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
|
||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("collapses a data URI on a short line", () => {
|
||||
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
|
||||
expect(collapsedRanges(state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("still leaves an ordinary document completely untouched", () => {
|
||||
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
|
||||
"\n",
|
||||
);
|
||||
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media rule only, for a document being edited", () => {
|
||||
/** What an editable editor gets: rule 2 alone */
|
||||
function editableRanges(state: EditorState) {
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
||||
while (iter.value != null) {
|
||||
ranges.push({ from: iter.from, to: iter.to });
|
||||
iter.next();
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function png(bytes: number) {
|
||||
const b = new Uint8Array(bytes);
|
||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
let binary = "";
|
||||
for (const byte of b) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
test("collapses a pasted image, the one thing it is for", () => {
|
||||
const value = png(3_000);
|
||||
const state = jsonState(`{\n "avatar": "${value}"\n}`);
|
||||
|
||||
expect(editableRanges(state)).toHaveLength(1);
|
||||
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
|
||||
value,
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves a long value alone when nothing can name it", () => {
|
||||
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
|
||||
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
|
||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(editableRanges(jsonState(doc))).toEqual([]);
|
||||
});
|
||||
|
||||
test("never cuts at the column limit, which would strand text out of reach", () => {
|
||||
// Rule 3 territory: a minified body with no value big enough to collapse on its own
|
||||
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
|
||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(editableRanges(jsonState(doc))).toEqual([]);
|
||||
// The read-only rules still cut it
|
||||
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("never swallows a template tag, collapsing only the run beside it", () => {
|
||||
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
|
||||
// on screen where it can still be read and edited
|
||||
const tag = "{{ image }}";
|
||||
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
|
||||
const ranges = editableRanges(jsonState(doc));
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
|
||||
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
|
||||
});
|
||||
|
||||
test("finds a data URI whole, header and all", () => {
|
||||
const value = `data:image/jpeg;base64,${png(3_000)}`;
|
||||
const doc = `{"avatar":"${value}"}`;
|
||||
const ranges = editableRanges(jsonState(doc));
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
||||
});
|
||||
|
||||
test("hides no line break, so the plugin may provide it from the viewport", () => {
|
||||
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
|
||||
for (const { from, to } of editableRanges(state)) {
|
||||
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("templated fields, where the grammar is an overlay", () => {
|
||||
// Every editable field mixes its language with twig, which mounts the base language as an
|
||||
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
|
||||
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
|
||||
// case that has to keep working: an image pasted into a JSON request body.
|
||||
const twigState = (doc: string) =>
|
||||
EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
twig({
|
||||
base: jsonc(),
|
||||
environmentVariables: [],
|
||||
completionOptions: [],
|
||||
onClickVariable: () => {},
|
||||
onClickMissingVariable: () => {},
|
||||
onClickPathParameter: () => {},
|
||||
extraExtensions: [],
|
||||
}),
|
||||
largeValues,
|
||||
],
|
||||
});
|
||||
|
||||
function png(bytes: number) {
|
||||
const b = new Uint8Array(bytes);
|
||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
let binary = "";
|
||||
for (const byte of b) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
test("collapses a pasted image in a templated body", () => {
|
||||
const value = png(3_000);
|
||||
const state = twigState(`{\n "avatar": "${value}"\n}`);
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
||||
while (iter.value != null) {
|
||||
ranges.push({ from: iter.from, to: iter.to });
|
||||
iter.next();
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewport scoping", () => {
|
||||
const doc = () => {
|
||||
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
||||
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
|
||||
};
|
||||
|
||||
test("decorates only the ranges it is given", () => {
|
||||
const state = jsonState(doc());
|
||||
const secondLine = state.doc.line(3);
|
||||
|
||||
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
|
||||
|
||||
expect(all.size).toBe(3);
|
||||
expect(one.size).toBe(1);
|
||||
});
|
||||
|
||||
test("covers a line the range only partly overlaps", () => {
|
||||
// The viewport can start mid-line; the collapse still has to span the whole value
|
||||
const state = jsonState(doc());
|
||||
const line = state.doc.line(2);
|
||||
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
|
||||
|
||||
expect(partial.size).toBe(1);
|
||||
const iter = partial.iter();
|
||||
expect(iter.to).toBeGreaterThan(line.from + 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { EditorState, Extension, Range } from "@codemirror/state";
|
||||
import type { Tree as SyntaxTree } from "@lezer/common";
|
||||
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||
import { fireAndForget } from "../../../lib/fireAndForget";
|
||||
import type { SniffedValue } from "./sniffValue";
|
||||
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||
|
||||
/**
|
||||
* How much of a line may be rendered before the rest is collapsed.
|
||||
*
|
||||
* VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
|
||||
* reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
|
||||
* collapse to a placeholder that can be opened instead.
|
||||
*/
|
||||
export const MAX_VISIBLE_LINE_CHARS = 10_000;
|
||||
|
||||
/**
|
||||
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
|
||||
* enough that nothing a person might actually read is ever hidden on length alone.
|
||||
*/
|
||||
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
||||
|
||||
/**
|
||||
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
|
||||
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
|
||||
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
|
||||
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
|
||||
* can't identify stays visible until it's big enough to be a problem on its own.
|
||||
*/
|
||||
export const COLLAPSE_MEDIA_CHARS = 1_000;
|
||||
|
||||
/**
|
||||
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
|
||||
* is only noise on screen.
|
||||
*
|
||||
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
|
||||
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
|
||||
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
|
||||
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
|
||||
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
|
||||
*
|
||||
* Three rules:
|
||||
*
|
||||
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
||||
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
|
||||
* values keeps every one of its keys. Needs a grammar to find the value.
|
||||
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
|
||||
* which is what catches an ordinary embedded image long before it is a rendering problem.
|
||||
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
|
||||
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
|
||||
* line that is simply long.
|
||||
*
|
||||
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
|
||||
* looked at, so a document of ordinary short lines costs a length check per visible line and
|
||||
* nothing else — no grammar is consulted and no tree is walked.
|
||||
*
|
||||
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
||||
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
|
||||
*
|
||||
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
|
||||
* gives its format up in the first dozen bytes — so the tag can name the format and open the
|
||||
* value in the matching viewer without any of it being read. See {@link sniffValue}.
|
||||
*
|
||||
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
|
||||
* by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
|
||||
* which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
|
||||
* So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
|
||||
* they hide text by length alone, without being able to say what it is: under rule 3 a minified
|
||||
* body would become uneditable past the column limit, which really would be editing text you
|
||||
* can't see.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which of the three rules to apply.
|
||||
*
|
||||
* `media` is the subset safe for a document being edited: collapse only what we can name.
|
||||
*/
|
||||
export type CollapseRules = "all" | "media";
|
||||
|
||||
/**
|
||||
* A hidden range, the value it belongs to, and what that value turned out to be.
|
||||
*
|
||||
* The hidden range and the value differ under the column rule, where the value starts at the
|
||||
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
|
||||
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
|
||||
*/
|
||||
interface Collapse {
|
||||
from: number;
|
||||
to: number;
|
||||
valueFrom: number;
|
||||
sniffed: SniffedValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
|
||||
* rendering React here would leave it empty while CodeMirror measures line heights.
|
||||
*/
|
||||
const CHEVRON_DOWN =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
||||
|
||||
/**
|
||||
* Marks the tag as the thing that opens the menu.
|
||||
*
|
||||
* The whole tag is the target rather than the chevron alone: there is only one action, and a
|
||||
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
|
||||
* itself. The chevron stays as the affordance that says so.
|
||||
*
|
||||
* A span with a role rather than a real button, because a button's box model makes the line
|
||||
* taller — the one thing this extension exists to keep from happening.
|
||||
*/
|
||||
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
|
||||
el.role = "button";
|
||||
el.ariaHasPopup = "menu";
|
||||
el.tabIndex = 0;
|
||||
el.title = title;
|
||||
el.ariaLabel = title;
|
||||
|
||||
// Keep the editor from moving the cursor when the tag is pressed
|
||||
el.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
el.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
});
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
});
|
||||
}
|
||||
|
||||
class LargeValueWidget extends WidgetType {
|
||||
constructor(
|
||||
private readonly from: number,
|
||||
private readonly to: number,
|
||||
private readonly valueFrom: number,
|
||||
private readonly sniffed: SniffedValue | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: LargeValueWidget) {
|
||||
return (
|
||||
other.from === this.from &&
|
||||
other.to === this.to &&
|
||||
other.valueFrom === this.valueFrom &&
|
||||
other.sniffed?.mime === this.sniffed?.mime
|
||||
);
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const length = this.to - this.from;
|
||||
|
||||
const el = document.createElement("span");
|
||||
// The same neutral tag styling a path parameter uses. The theme scope matters: these
|
||||
// tokens resolve against the tag palette, not the editor's ambient one, where a
|
||||
// tag-sized border is meant to be near invisible.
|
||||
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
||||
|
||||
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
|
||||
if (thumbnail != null) {
|
||||
el.appendChild(thumbnail);
|
||||
}
|
||||
|
||||
const label = document.createElement("span");
|
||||
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
|
||||
// Without a name there is nothing to show but the fact that something is missing.
|
||||
label.textContent =
|
||||
this.sniffed == null
|
||||
? `${formatSize(length)} hidden…`
|
||||
: `${this.sniffed.label} · ${formatSize(length)}`;
|
||||
el.appendChild(label);
|
||||
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = "tag-action";
|
||||
chevron.ariaHidden = "true";
|
||||
chevron.innerHTML = CHEVRON_DOWN;
|
||||
el.appendChild(chevron);
|
||||
|
||||
makeTagButton(
|
||||
el,
|
||||
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
||||
() => fireAndForget(this.openMenu(view, el)),
|
||||
);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy, view and save, in a menu opened against the button.
|
||||
*
|
||||
* Everything the menu needs is pulled in on click. The editor loads on every response, and
|
||||
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
|
||||
* asks for them.
|
||||
*/
|
||||
private async openMenu(view: EditorView, tag: HTMLElement) {
|
||||
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
||||
await Promise.all([
|
||||
import("../../../lib/contextMenu"),
|
||||
import("../../LargeValueDialog"),
|
||||
import("../../../lib/largeValue"),
|
||||
]);
|
||||
|
||||
const { sniffed } = this;
|
||||
// Sliced when an action runs rather than now, so opening the menu never touches the value
|
||||
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
|
||||
const head = view.state.sliceDoc(
|
||||
this.valueFrom,
|
||||
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
|
||||
);
|
||||
|
||||
// The whole tag, so the menu can align to whichever edge has room beside it
|
||||
const rect = tag.getBoundingClientRect();
|
||||
toggleContextMenu({
|
||||
id: "large-value",
|
||||
triggerPosition: { x: rect.left, y: rect.bottom },
|
||||
triggerRect: rect,
|
||||
triggerEl: tag,
|
||||
items: [
|
||||
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
||||
...largeValueActions({
|
||||
value,
|
||||
sniffed,
|
||||
// Exactly what the tag stands in for, which under the column rule is only the tail
|
||||
copyText: () => view.state.sliceDoc(this.from, this.to),
|
||||
onView: () => showLargeValueDialog({ text: value(), sniffed }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
|
||||
*
|
||||
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
|
||||
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
|
||||
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
|
||||
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
|
||||
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
|
||||
* that previews some images and not others is worse than one that previews none. Only widgets in
|
||||
* the viewport are ever built, so a screenful is the most that decode at once.
|
||||
*/
|
||||
const THUMBNAIL_MAX_CHARS = 4_000_000;
|
||||
|
||||
/**
|
||||
* A preview of the value, at a size fixed before it loads.
|
||||
*
|
||||
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
|
||||
* the line's height or the tag's width. A growing line box is the layout cost this whole
|
||||
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
|
||||
*
|
||||
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
|
||||
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
|
||||
* thing that must not happen here.
|
||||
*/
|
||||
function buildThumbnail(
|
||||
view: EditorView,
|
||||
valueFrom: number,
|
||||
to: number,
|
||||
sniffed: SniffedValue | null,
|
||||
): HTMLElement | null {
|
||||
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
|
||||
if (sniffed.encoding !== "base64") return null;
|
||||
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "tag-thumbnail";
|
||||
img.alt = "";
|
||||
img.ariaHidden = "true";
|
||||
img.decoding = "async";
|
||||
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
|
||||
// broken-image glyph sitting in the tag.
|
||||
img.addEventListener("error", () => img.remove());
|
||||
// Read the document and build the data URI after the widget is measured and on screen
|
||||
requestAnimationFrame(() => {
|
||||
if (!img.isConnected) return;
|
||||
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
|
||||
img.src = `data:${sniffed.mime};base64,${payload}`;
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines the given ranges touch, in document order and each listed once.
|
||||
*
|
||||
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
|
||||
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
|
||||
*/
|
||||
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
|
||||
const lines: Line[] = [];
|
||||
let lastFrom = -1;
|
||||
|
||||
for (const range of ranges) {
|
||||
let pos = range.from;
|
||||
for (;;) {
|
||||
const line = state.doc.lineAt(pos);
|
||||
if (line.from > lastFrom) {
|
||||
lastFrom = line.from;
|
||||
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
|
||||
lines.push({ from: line.from, to: line.to });
|
||||
}
|
||||
}
|
||||
if (line.to >= range.to) break;
|
||||
pos = line.to + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to spend parsing before falling back to the column rule.
|
||||
*
|
||||
* The initial parse is budgeted by time, so it stops partway through a document with several
|
||||
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
|
||||
* 12 ms, against the 171 ms of layout it saves.
|
||||
*/
|
||||
const PARSE_TIMEOUT_MS = 100;
|
||||
|
||||
/**
|
||||
* The parsed tree covering these lines.
|
||||
*
|
||||
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
|
||||
* inside it is what saves the frame. For everything else we take whatever the background parse
|
||||
* has reached and decorate again when it advances — a forced parse runs from the top of the
|
||||
* document, so making one happen on every scroll would cost far more than the tags are worth.
|
||||
*/
|
||||
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
|
||||
const last = lines[lines.length - 1];
|
||||
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
|
||||
if (last == null || !stalls) {
|
||||
return syntaxTree(state);
|
||||
}
|
||||
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
||||
}
|
||||
|
||||
const QUOTES = ['"', "'", "`"];
|
||||
|
||||
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
|
||||
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
|
||||
|
||||
/** The header that turns a bare run into a data URI, when one sits right before it. */
|
||||
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
|
||||
|
||||
/**
|
||||
* Encoded media on this line, found by reading the text rather than the grammar.
|
||||
*
|
||||
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
|
||||
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
|
||||
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
|
||||
* inside it. Rule 2 has to work in those fields above all, since that is where images get
|
||||
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
|
||||
* the alphabet does.
|
||||
*
|
||||
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
|
||||
* as one thing and its declared type is what names it.
|
||||
*/
|
||||
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
|
||||
const text = state.sliceDoc(line.from, line.to);
|
||||
const collapses: Collapse[] = [];
|
||||
|
||||
ENCODED_RUN.lastIndex = 0;
|
||||
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
|
||||
let start = m.index;
|
||||
// A data URI's own header breaks the alphabet, so the run starts after it
|
||||
const header = DATA_URI_HEAD.exec(text.slice(0, start));
|
||||
if (header != null) {
|
||||
start = header.index;
|
||||
}
|
||||
|
||||
const valueFrom = line.from + start;
|
||||
const valueTo = line.from + m.index + m[0].length;
|
||||
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||
const sniffed = sniffValue(head);
|
||||
|
||||
// Only hide what we can name. A short magic number matches by chance now and then, so the
|
||||
// run itself has to look encoded too.
|
||||
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
|
||||
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
|
||||
}
|
||||
}
|
||||
|
||||
return collapses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
|
||||
*
|
||||
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
|
||||
* nobody is editing — which is also the only place this rule applies.
|
||||
*/
|
||||
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
||||
const collapses: Collapse[] = [];
|
||||
|
||||
tree.iterate({
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
enter: (node) => {
|
||||
// A node this small can't contain anything worth collapsing, and neither can its children
|
||||
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
|
||||
// Only leaves, so we collapse the string itself rather than the object holding it
|
||||
if (node.node.firstChild != null) return true;
|
||||
|
||||
const from = Math.max(node.from, line.from);
|
||||
const to = Math.min(node.to, line.to);
|
||||
const open = state.sliceDoc(from, from + 1);
|
||||
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
||||
if (!quoted) return false;
|
||||
|
||||
// Everything inside the quotes. The whole value is hidden, so it is its own value range
|
||||
const valueFrom = from + 1;
|
||||
const valueTo = to - 1;
|
||||
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
|
||||
|
||||
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return collapses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both rules' findings as one list, in document order, with nothing overlapping.
|
||||
*
|
||||
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
|
||||
* error. The token wins where they collide, because its range stops at the quotes and so leaves
|
||||
* the structure around it readable.
|
||||
*/
|
||||
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
|
||||
const merged = [...tokens];
|
||||
for (const run of runs) {
|
||||
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
|
||||
merged.push(run);
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the line runs past the column limit, counting only what is still visible after the
|
||||
* token collapses, or -1 if it fits.
|
||||
*/
|
||||
function findColumnCut(line: Line, tokens: Collapse[]): number {
|
||||
let visible = 0;
|
||||
let pos = line.from;
|
||||
|
||||
for (const token of [...tokens, null]) {
|
||||
const segmentEnd = token == null ? line.to : token.from;
|
||||
if (segmentEnd > pos) {
|
||||
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
|
||||
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
|
||||
}
|
||||
visible += segmentEnd - pos;
|
||||
}
|
||||
if (token != null) {
|
||||
pos = token.to;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collapsesForLine(
|
||||
state: EditorState,
|
||||
tree: SyntaxTree,
|
||||
line: Line,
|
||||
rules: CollapseRules,
|
||||
): Collapse[] {
|
||||
const runs = findMediaRuns(state, line);
|
||||
// Length alone only hides things on a line that would stall without it, and never in a
|
||||
// document being edited, where hiding text by size would put it out of reach
|
||||
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
|
||||
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
|
||||
|
||||
// Short enough to render, so there is nothing left to cut
|
||||
if (!stalls) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const cut = findColumnCut(line, tokens);
|
||||
if (cut < 0) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// The cut always lands in a visible stretch, so it never splits a token collapse
|
||||
const kept = tokens.filter((t) => t.to <= cut);
|
||||
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
|
||||
// body that is nothing but one base64 blob is still recognisable from there
|
||||
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
|
||||
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapses covering the given document ranges.
|
||||
*
|
||||
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
|
||||
* view and no DOM.
|
||||
*/
|
||||
export function collapseDecorations(
|
||||
state: EditorState,
|
||||
ranges: readonly { from: number; to: number }[],
|
||||
rules: CollapseRules = "all",
|
||||
): DecorationSet {
|
||||
const lines = collapsibleLines(state, ranges);
|
||||
if (lines.length === 0) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const tree = treeForLines(state, lines);
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
for (const line of lines) {
|
||||
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
|
||||
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
|
||||
decorations.push(Decoration.replace({ widget }).range(from, to));
|
||||
}
|
||||
}
|
||||
return Decoration.set(decorations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped to the viewport, so the cost is a screenful however big the document is.
|
||||
*
|
||||
* Recomputed when the document changes, when the viewport moves, and when background parsing
|
||||
* advances — a value can only be found once the grammar has reached it.
|
||||
*/
|
||||
function collapsePlugin(rules: CollapseRules) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
) {
|
||||
this.decorations = collapseDecorations(
|
||||
update.view.state,
|
||||
update.view.visibleRanges,
|
||||
rules,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** All three rules. For a document nobody is editing. */
|
||||
export const largeValues: Extension = [collapsePlugin("all")];
|
||||
|
||||
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
|
||||
export const mediaValues: Extension = [collapsePlugin("media")];
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||
|
||||
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
|
||||
function base64Of(magic: number[], length = 2_000): string {
|
||||
const bytes = new Uint8Array(length);
|
||||
bytes.set(magic);
|
||||
for (let i = magic.length; i < length; i++) {
|
||||
bytes[i] = i % 251;
|
||||
}
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** One character, one byte. These are all ASCII signatures. */
|
||||
function ascii(s: string): number[] {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
bytes.push(s.charCodeAt(i));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
|
||||
const IGNORED = [0x00, 0x00, 0x00, 0x20];
|
||||
|
||||
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
||||
const PDF = ascii("%PDF-1.7");
|
||||
|
||||
describe("magic numbers", () => {
|
||||
const cases: [string, number[], string, string][] = [
|
||||
["PNG", PNG, "image/png", "PNG"],
|
||||
["JPEG", JPEG, "image/jpeg", "JPEG"],
|
||||
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
|
||||
["PDF", PDF, "application/pdf", "PDF"],
|
||||
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
|
||||
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
|
||||
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
|
||||
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
|
||||
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
|
||||
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
|
||||
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
|
||||
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
|
||||
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
|
||||
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
|
||||
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
|
||||
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
|
||||
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
|
||||
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
|
||||
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
|
||||
];
|
||||
|
||||
for (const [name, magic, mime, label] of cases) {
|
||||
test(`recognises ${name}`, () => {
|
||||
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
|
||||
});
|
||||
}
|
||||
|
||||
test("recognises nothing in arbitrary base64", () => {
|
||||
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
|
||||
});
|
||||
|
||||
test("recognises nothing in text that isn't base64", () => {
|
||||
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
|
||||
});
|
||||
|
||||
test("recognises nothing in a JWT-shaped value", () => {
|
||||
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
|
||||
});
|
||||
|
||||
test("reads the url-safe alphabet", () => {
|
||||
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
|
||||
expect(sniffValue(urlSafe)?.label).toBe("PNG");
|
||||
});
|
||||
|
||||
test("ignores leading whitespace, and counts it in the offset", () => {
|
||||
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("data URIs", () => {
|
||||
test("takes the declared type over the bytes", () => {
|
||||
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
|
||||
expect(sniffValue(uri)).toEqual({
|
||||
mime: "image/svg+xml",
|
||||
label: "SVG",
|
||||
offset: "data:image/svg+xml;base64,".length,
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
test("points past the header, so the payload can be decoded from there", () => {
|
||||
const payload = base64Of(PNG);
|
||||
const uri = `data:image/png;base64,${payload}`;
|
||||
const sniffed = sniffValue(uri)!;
|
||||
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
|
||||
});
|
||||
|
||||
test("falls back to the bytes when the declared type says nothing", () => {
|
||||
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
|
||||
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
|
||||
});
|
||||
|
||||
test("handles a header with no type at all", () => {
|
||||
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
|
||||
});
|
||||
|
||||
test("handles extra parameters", () => {
|
||||
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
|
||||
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
|
||||
});
|
||||
|
||||
test("handles a percent-encoded payload", () => {
|
||||
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
|
||||
mime: "text/html",
|
||||
label: "HTML",
|
||||
offset: "data:text/html,".length,
|
||||
encoding: "percent",
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults a bare percent-encoded payload to text", () => {
|
||||
expect(sniffValue("data:,hello%20there")).toMatchObject({
|
||||
mime: "text/plain",
|
||||
encoding: "percent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decoding only the head", () => {
|
||||
test("reads no more of the value than the bytes asked for", () => {
|
||||
const big = base64Of(PNG, 3_000_000);
|
||||
// Truncating to the head must not change what is found, which is the whole point:
|
||||
// callers only ever hand it a slice
|
||||
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
|
||||
});
|
||||
|
||||
test("decodes whole 4-character groups only", () => {
|
||||
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
|
||||
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
|
||||
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
|
||||
});
|
||||
|
||||
test("declines a value too short to hold a group", () => {
|
||||
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
|
||||
});
|
||||
|
||||
test("declines text outside the alphabet", () => {
|
||||
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("labels", () => {
|
||||
test.each([
|
||||
["image/png", "PNG"],
|
||||
["image/svg+xml", "SVG"],
|
||||
["text/plain", "TEXT"],
|
||||
["application/vnd.ms-excel", "MS-EXCEL"],
|
||||
["image/x-icon", "ICON"],
|
||||
["application/octet-stream", "APPLICATION"],
|
||||
["font/woff2", "WOFF2"],
|
||||
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
|
||||
])("%s reads as %s", (mime, label) => {
|
||||
expect(labelForMime(mime)).toBe(label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* What a collapsed value turns out to be.
|
||||
*
|
||||
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
|
||||
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
|
||||
* point, since the whole reason these values are collapsed is that touching all of one costs
|
||||
* enough to stall the UI.
|
||||
*/
|
||||
export interface SniffedValue {
|
||||
/** Full media type, e.g. `image/png`. What the dialog routes on. */
|
||||
mime: string;
|
||||
/** A word for the tag, e.g. `PNG`. */
|
||||
label: string;
|
||||
/** Where the encoded payload starts in the value, past any `data:` header. */
|
||||
offset: number;
|
||||
encoding: "base64" | "percent";
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
|
||||
* length, and far more than the 24 base64 characters the magic numbers are read from.
|
||||
*/
|
||||
export const SNIFF_HEAD_CHARS = 256;
|
||||
|
||||
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
|
||||
const SNIFF_BYTES = 12;
|
||||
|
||||
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
|
||||
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
|
||||
|
||||
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
|
||||
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
|
||||
|
||||
interface Signature {
|
||||
label: string;
|
||||
mime: string;
|
||||
offset?: number;
|
||||
/** Byte values, where `null` matches anything */
|
||||
magic: readonly (number | null)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
|
||||
*
|
||||
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
|
||||
* is one byte, and splitting into code points would be the wrong unit for that.
|
||||
*/
|
||||
function ascii(pattern: string): (number | null)[] {
|
||||
const bytes: (number | null)[] = [];
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const SIGNATURES: readonly Signature[] = [
|
||||
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
|
||||
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
|
||||
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
|
||||
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
|
||||
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
|
||||
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
|
||||
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
|
||||
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
|
||||
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
|
||||
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
|
||||
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
|
||||
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
|
||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
|
||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
|
||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
|
||||
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
|
||||
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
|
||||
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
|
||||
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
|
||||
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
|
||||
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
|
||||
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
|
||||
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
|
||||
];
|
||||
|
||||
/**
|
||||
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
|
||||
* than in the signature itself. Anything unlisted is some flavour of MP4.
|
||||
*/
|
||||
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
|
||||
avif: { mime: "image/avif", label: "AVIF" },
|
||||
avis: { mime: "image/avif", label: "AVIF" },
|
||||
heic: { mime: "image/heic", label: "HEIC" },
|
||||
heix: { mime: "image/heic", label: "HEIC" },
|
||||
hevc: { mime: "image/heic", label: "HEIC" },
|
||||
mif1: { mime: "image/heif", label: "HEIF" },
|
||||
msf1: { mime: "image/heif", label: "HEIF" },
|
||||
"M4A ": { mime: "audio/mp4", label: "M4A" },
|
||||
"qt ": { mime: "video/quicktime", label: "MOV" },
|
||||
};
|
||||
|
||||
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
|
||||
if (bytes.length < offset + magic.length) return false;
|
||||
return magic.every((b, i) => b == null || bytes[offset + i] === b);
|
||||
}
|
||||
|
||||
function readAscii(bytes: Uint8Array, from: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.subarray(from, from + length));
|
||||
}
|
||||
|
||||
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
|
||||
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
|
||||
if (readAscii(bytes, 4, 4) === "ftyp") {
|
||||
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
|
||||
}
|
||||
const sig = SIGNATURES.find((s) => matches(bytes, s));
|
||||
return sig == null ? null : { mime: sig.mime, label: sig.label };
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
|
||||
*
|
||||
* Only the characters those bytes need are decoded. The trailing partial group is dropped
|
||||
* rather than padded, since `atob` rejects a group of one or two characters outright and we
|
||||
* have no use for the byte a three-character group would add.
|
||||
*/
|
||||
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
|
||||
const wanted = Math.ceil(bytes / 3) * 4;
|
||||
let b64 = text.slice(offset, offset + wanted);
|
||||
b64 = b64.slice(0, b64.length - (b64.length % 4));
|
||||
if (b64.length === 0) return null;
|
||||
|
||||
// The URL-safe alphabet stands for the same bytes
|
||||
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
|
||||
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
|
||||
|
||||
try {
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a value is, from its head alone, or null if nothing recognises it.
|
||||
*
|
||||
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
|
||||
* ignored. The offsets in the result are relative to the start of the whole value.
|
||||
*/
|
||||
export function sniffValue(head: string): SniffedValue | null {
|
||||
const lead = head.length - head.trimStart().length;
|
||||
const value = head.slice(lead);
|
||||
|
||||
const dataUri = DATA_URI.exec(value);
|
||||
if (dataUri != null) {
|
||||
const declared = dataUri[1]!.toLowerCase();
|
||||
const offset = lead + dataUri[0].length;
|
||||
if (!dataUri[2]!.split(";").includes("base64")) {
|
||||
// A percent-encoded payload states its own type or is text by definition
|
||||
const mime = declared === "" ? "text/plain" : declared;
|
||||
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
|
||||
}
|
||||
if (!UNINFORMATIVE.includes(declared)) {
|
||||
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
|
||||
}
|
||||
return sniffBase64At(head, offset);
|
||||
}
|
||||
|
||||
return sniffBase64At(head, lead);
|
||||
}
|
||||
|
||||
function sniffBase64At(head: string, offset: number): SniffedValue | null {
|
||||
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
|
||||
const sniffed = bytes == null ? null : sniffBytes(bytes);
|
||||
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
|
||||
}
|
||||
|
||||
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
|
||||
const ENCODED_RUN_CHARS = 64;
|
||||
|
||||
/**
|
||||
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
|
||||
*
|
||||
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
|
||||
* up about once in every 65,000 values. That was harmless while a value had to be long enough
|
||||
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
|
||||
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
|
||||
* prose leaves the alphabet within a few characters, and an encoded blob never does.
|
||||
*/
|
||||
export function isEncodedRun(head: string, offset: number): boolean {
|
||||
const run = head.slice(offset);
|
||||
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
|
||||
}
|
||||
|
||||
/** Subtypes whose own name makes a poor label */
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
"text/plain": "TEXT",
|
||||
};
|
||||
|
||||
/**
|
||||
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
|
||||
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
|
||||
* as MS-EXCEL. Anything still too long falls back to the top-level type.
|
||||
*/
|
||||
export function labelForMime(mime: string): string {
|
||||
const override = LABEL_OVERRIDES[mime];
|
||||
if (override != null) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const [type, subtype] = mime.split("/");
|
||||
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
|
||||
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
|
||||
return word.toUpperCase();
|
||||
}
|
||||
return (type ?? mime).toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import { EditorState, type TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
|
||||
|
||||
describe("applyUrlCompletion", () => {
|
||||
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
|
||||
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
|
||||
"http://rickandmortyapi.com/api/character",
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts a protocol when there is no existing suffix", () => {
|
||||
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
|
||||
});
|
||||
|
||||
test("replaces the full URL when accepting a saved URL", () => {
|
||||
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
|
||||
"https://new.example/api",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrlCompletionConfig", () => {
|
||||
test("always includes protocols alongside saved URL options", () => {
|
||||
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
|
||||
|
||||
expect(config.options.map((option) => option.label)).toEqual([
|
||||
"http://",
|
||||
"https://",
|
||||
"https://example.com",
|
||||
]);
|
||||
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function applyCompletion(document: string, label: string, cursor: number) {
|
||||
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
|
||||
const view = {
|
||||
state,
|
||||
dispatch: (spec: TransactionSpec) => {
|
||||
state = state.update(spec).state;
|
||||
},
|
||||
} as unknown as EditorView;
|
||||
|
||||
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
|
||||
return state.doc.toString();
|
||||
}
|
||||
@@ -1,9 +1,51 @@
|
||||
import { genericCompletion } from "../genericCompletion";
|
||||
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
genericCompletion,
|
||||
type GenericCompletion,
|
||||
type GenericCompletionConfig,
|
||||
} from "../genericCompletion";
|
||||
|
||||
export const completions = genericCompletion({
|
||||
options: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
minMatch: 1,
|
||||
});
|
||||
const protocolOptions: GenericCompletionOption[] = [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
];
|
||||
|
||||
export function getUrlCompletionConfig(
|
||||
options: GenericCompletionOption[],
|
||||
minMatch = 3,
|
||||
): GenericCompletionConfig {
|
||||
const urlOptions = [
|
||||
...protocolOptions,
|
||||
...options.filter(
|
||||
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
|
||||
),
|
||||
];
|
||||
return {
|
||||
minMatch,
|
||||
options: urlOptions.map<GenericCompletion>((option) => ({
|
||||
...option,
|
||||
apply: applyUrlCompletion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyUrlCompletion(
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
const isProtocol = /^https?:\/\/$/.test(completion.label);
|
||||
const replaceTo = isProtocol
|
||||
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
|
||||
: view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
...insertCompletionText(view.state, completion.label, from, replaceTo),
|
||||
annotations: pickedCompletion.of(completion),
|
||||
});
|
||||
}
|
||||
|
||||
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { HotkeyList } from "./HotkeyList";
|
||||
|
||||
vi.mock("./Hotkey", () => ({
|
||||
Hotkey: ({ action }: { action: HotkeyAction }) =>
|
||||
action === "sidebar.selected.move" ? null : <span>{action}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./HotkeyLabel", () => ({
|
||||
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
|
||||
}));
|
||||
|
||||
describe("HotkeyList", () => {
|
||||
test("keeps a grid cell for actions without a shortcut", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain(
|
||||
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
||||
{hotkeys.map((hotkey) => (
|
||||
<Fragment key={hotkey}>
|
||||
<HotkeyLabel className="truncate" action={hotkey} />
|
||||
<Hotkey className="ml-4" action={hotkey} />
|
||||
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||
<div className="ml-4">
|
||||
<Hotkey action={hotkey} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{bottomSlot}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button } from "./Button";
|
||||
import type { DropdownItem } from "./Dropdown";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import type { EditorProps } from "./Editor/Editor";
|
||||
import type { GenericCompletion } from "./Editor/genericCompletion";
|
||||
import { Editor } from "./Editor/LazyEditor";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
@@ -63,6 +64,13 @@ export type InputProps = Pick<
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
/**
|
||||
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
|
||||
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
|
||||
* wanting the affordance (the URL bar completes from history, and a chevron there would be
|
||||
* noise). Renders nothing when there are no options to show.
|
||||
*/
|
||||
showOptionsPicker?: boolean;
|
||||
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
stateKey: EditorProps["stateKey"];
|
||||
extraExtensions?: EditorProps["extraExtensions"];
|
||||
@@ -115,6 +123,7 @@ function BaseInput({
|
||||
readOnly,
|
||||
required,
|
||||
rightSlot,
|
||||
showOptionsPicker,
|
||||
size = "md",
|
||||
stateKey,
|
||||
tint,
|
||||
@@ -156,6 +165,17 @@ function BaseInput({
|
||||
[],
|
||||
);
|
||||
|
||||
// Replace the whole value with a picked option. Dispatching runs it through the editor's
|
||||
// onChange, same as typing, so callers see one consistent change path.
|
||||
const insertOption = useCallback((value: string) => {
|
||||
const view = editorRef.current;
|
||||
if (view == null) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||
view.focus();
|
||||
}, []);
|
||||
|
||||
const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
|
||||
|
||||
const setEditorRef = useCallback(
|
||||
(h: EditorView | null) => {
|
||||
editorRef.current = h;
|
||||
@@ -318,6 +338,7 @@ function BaseInput({
|
||||
editorClassName,
|
||||
multiLine && size === "md" && "py-1.5",
|
||||
multiLine && size === "sm" && "py-1",
|
||||
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
@@ -349,12 +370,55 @@ function BaseInput({
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{pickerOptions.length > 0 && (
|
||||
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
|
||||
* typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
|
||||
* short name (e.g. a User-Agent).
|
||||
*
|
||||
* Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
|
||||
* options, so this would only add a stop between fields. Hidden when the nearest container is
|
||||
* narrow and the input needs the width more — written as a `max` query so it stays visible for
|
||||
* inputs that have no container ancestor at all.
|
||||
*/
|
||||
function OptionsPickerDropdown({
|
||||
options,
|
||||
onSelect,
|
||||
}: {
|
||||
options: GenericCompletion[];
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
const items = useMemo<DropdownItem[]>(
|
||||
() =>
|
||||
options.map((o) => ({
|
||||
label: o.label,
|
||||
onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
|
||||
})),
|
||||
[options, onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon="chevron_down"
|
||||
title="Show suggestions"
|
||||
className="mr-0.5 h-auto! my-0.5 text-text-subtlest @max-[24rem]:hidden"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
@@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { RadioDropdown } from "./RadioDropdown";
|
||||
|
||||
export interface PairEditorHandle {
|
||||
/**
|
||||
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
|
||||
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
|
||||
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
|
||||
*/
|
||||
focusName(id: string): void;
|
||||
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
|
||||
focusValue(id: string): void;
|
||||
}
|
||||
|
||||
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
|
||||
const MAX_FOCUS_ATTEMPTS = 60;
|
||||
|
||||
export type PairEditorProps = {
|
||||
allowFileValues?: boolean;
|
||||
allowMultilineValues?: boolean;
|
||||
@@ -53,7 +62,7 @@ export type PairEditorProps = {
|
||||
nameValidate?: InputProps["validate"];
|
||||
noScroll?: boolean;
|
||||
onChange: (pairs: PairWithId[]) => void;
|
||||
pairs: Pair[];
|
||||
pairs: EditablePair[];
|
||||
stateKey: InputProps["stateKey"];
|
||||
setRef?: (n: PairEditorHandle) => void;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
@@ -72,13 +81,35 @@ export type Pair = {
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
isFile?: boolean;
|
||||
readOnlyName?: boolean;
|
||||
};
|
||||
|
||||
export type PairWithId = Pair & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
|
||||
* `Pair` stays the shape that gets written to models.
|
||||
*/
|
||||
export type EditablePair = Pair & {
|
||||
/**
|
||||
* When set, name edits are held until the field blurs and then committed through this, instead
|
||||
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
|
||||
* the field. For names that can't be written directly, like a URL path placeholder that lives
|
||||
* in the URL itself.
|
||||
*/
|
||||
commitName?: (name: string) => boolean;
|
||||
};
|
||||
|
||||
type EditablePairWithId = EditablePair & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** Strip the editor-only fields, so they can never reach a model write */
|
||||
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
|
||||
return pair;
|
||||
}
|
||||
|
||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||
const MAX_INITIAL_PAIRS = 30;
|
||||
|
||||
@@ -106,8 +137,8 @@ export function PairEditor({
|
||||
setRef,
|
||||
}: PairEditorProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
||||
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||
const [showAll, toggleShowAll] = useToggle(false);
|
||||
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
||||
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
|
||||
@@ -115,16 +146,38 @@ export function PairEditor({
|
||||
|
||||
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
||||
|
||||
const pendingFocusFrame = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
|
||||
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
|
||||
|
||||
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
|
||||
const attempt = () => {
|
||||
pendingFocusFrame.current = null;
|
||||
const row = rowsRef.current[id];
|
||||
const landed = field === "name" ? row?.focusName() : row?.focusValue();
|
||||
if (landed || --attemptsLeft <= 0) return;
|
||||
pendingFocusFrame.current = requestAnimationFrame(attempt);
|
||||
};
|
||||
attempt();
|
||||
}, []);
|
||||
|
||||
const handle = useMemo<PairEditorHandle>(
|
||||
() => ({
|
||||
focusName(id: string) {
|
||||
rowsRef.current[id]?.focusName();
|
||||
focusWhenReady(id, "name");
|
||||
},
|
||||
focusValue(id: string) {
|
||||
rowsRef.current[id]?.focusValue();
|
||||
focusWhenReady(id, "value");
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[focusWhenReady],
|
||||
);
|
||||
|
||||
const initPairEditorRow = useCallback(
|
||||
@@ -147,7 +200,7 @@ export function PairEditor({
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
||||
useEffect(() => {
|
||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
||||
const newPairs: PairWithId[] = [];
|
||||
const newPairs: EditablePairWithId[] = [];
|
||||
for (let i = 0; i < originalPairs.length; i++) {
|
||||
const p = originalPairs[i];
|
||||
if (!p) continue; // Make TS happy
|
||||
@@ -155,6 +208,21 @@ export function PairEditor({
|
||||
newPairs.push(ensurePairId(p));
|
||||
}
|
||||
|
||||
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
|
||||
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
|
||||
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
|
||||
// selection — like tabbing from a placeholder's name into its value.
|
||||
const trailingPair = pairs[pairs.length - 1];
|
||||
const sameRows =
|
||||
trailingPair != null &&
|
||||
isPairEmpty(trailingPair) &&
|
||||
pairs.length === newPairs.length + 1 &&
|
||||
newPairs.every((p, i) => p.id === pairs[i]?.id);
|
||||
if (sameRows) {
|
||||
setPairs([...newPairs, trailingPair]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add empty last pair if there is none
|
||||
const lastPair = newPairs[newPairs.length - 1];
|
||||
if (lastPair == null || !isPairEmpty(lastPair)) {
|
||||
@@ -166,10 +234,10 @@ export function PairEditor({
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
const setPairsAndSave = useCallback(
|
||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
||||
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const pairs = fn(oldPairs);
|
||||
onChange(pairs);
|
||||
onChange(pairs.map(toPairData));
|
||||
return pairs;
|
||||
});
|
||||
},
|
||||
@@ -177,7 +245,7 @@ export function PairEditor({
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(pair: PairWithId) =>
|
||||
(pair: EditablePairWithId) =>
|
||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
@@ -362,14 +430,14 @@ export function PairEditor({
|
||||
|
||||
type PairEditorRowProps = {
|
||||
className?: string;
|
||||
pair: PairWithId;
|
||||
pair: EditablePairWithId;
|
||||
forceFocusNamePairId?: string | null;
|
||||
forceFocusValuePairId?: string | null;
|
||||
onChange?: (pair: PairWithId) => void;
|
||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
||||
onFocusName?: (pair: PairWithId) => void;
|
||||
onFocusValue?: (pair: PairWithId) => void;
|
||||
onSubmit?: (pair: PairWithId) => void;
|
||||
onChange?: (pair: EditablePairWithId) => void;
|
||||
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
|
||||
onFocusName?: (pair: EditablePairWithId) => void;
|
||||
onFocusValue?: (pair: EditablePairWithId) => void;
|
||||
onSubmit?: (pair: EditablePairWithId) => void;
|
||||
isLast?: boolean;
|
||||
disabled?: boolean;
|
||||
disableDrag?: boolean;
|
||||
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
|
||||
>;
|
||||
|
||||
interface RowHandle {
|
||||
focusName(): void;
|
||||
focusValue(): void;
|
||||
focusName(): boolean;
|
||||
focusValue(): boolean;
|
||||
}
|
||||
|
||||
export function PairEditorRow({
|
||||
@@ -436,9 +504,11 @@ export function PairEditorRow({
|
||||
const handle = useRef<RowHandle>({
|
||||
focusName() {
|
||||
nameInputRef.current?.focus();
|
||||
return nameInputRef.current?.isFocused() ?? false;
|
||||
},
|
||||
focusValue() {
|
||||
valueInputRef.current?.focus();
|
||||
return valueInputRef.current?.isFocused() ?? false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,11 +541,37 @@ export function PairEditorRow({
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
// The name being typed into a deferred-commit field, before it's committed or reverted
|
||||
const pendingName = useRef<string | null>(null);
|
||||
|
||||
const handleChangeName = useMemo(
|
||||
() => (name: string) => onChange?.({ ...pair, name }),
|
||||
() => (name: string) => {
|
||||
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
|
||||
// beneath the cursor, since the pairs are derived from the name being edited.
|
||||
if (pair.commitName != null) pendingName.current = name;
|
||||
else onChange?.({ ...pair, name });
|
||||
},
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const revertName = useCallback(() => {
|
||||
const nameInput = nameInputRef.current;
|
||||
if (nameInput != null) {
|
||||
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
|
||||
nameInput.dispatch({ changes });
|
||||
}
|
||||
pendingName.current = null;
|
||||
}, [pair.name]);
|
||||
|
||||
const handleBlurName = useCallback(() => {
|
||||
if (pair.commitName == null) return;
|
||||
|
||||
const name = pendingName.current;
|
||||
pendingName.current = null;
|
||||
if (name == null || name === pair.name) return;
|
||||
if (!pair.commitName(name)) revertName();
|
||||
}, [pair, revertName]);
|
||||
|
||||
const handleChangeValueText = useMemo(
|
||||
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
||||
[onChange, pair],
|
||||
@@ -551,6 +647,10 @@ export function PairEditorRow({
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
|
||||
// imply it's already a real row. `Input` handles the rest, including having no options to show.
|
||||
const showOptionsPicker = !isLast;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetRef}
|
||||
@@ -596,7 +696,7 @@ export function PairEditorRow({
|
||||
stateKey={`name.${pair.id}.${stateKey}`}
|
||||
disabled={disabled}
|
||||
wrapLines={false}
|
||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
||||
readOnly={isDraggingGlobal}
|
||||
size="sm"
|
||||
required={!isLast && !!pair.enabled && !!pair.value}
|
||||
validate={nameValidate}
|
||||
@@ -606,12 +706,14 @@ export function PairEditorRow({
|
||||
defaultValue={pair.name}
|
||||
label="Name"
|
||||
name={`name[${index}]`}
|
||||
onBlur={handleBlurName}
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocusName}
|
||||
placeholder={namePlaceholder ?? "name"}
|
||||
autocomplete={nameAutocomplete}
|
||||
autocompleteVariables={nameAutocompleteVariables}
|
||||
autocompleteFunctions={nameAutocompleteFunctions}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||
{pair.isFile ? (
|
||||
@@ -656,6 +758,7 @@ export function PairEditorRow({
|
||||
autocomplete={valueAutocomplete?.(pair.name)}
|
||||
autocompleteFunctions={valueAutocompleteFunctions}
|
||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -675,8 +778,10 @@ export function PairEditorRow({
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon={isLast || disabled ? "empty" : "chevron_down"}
|
||||
title="Select form data type"
|
||||
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
|
||||
// picking a suggestion, and this menu acts on the row instead of filling in a field.
|
||||
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
|
||||
title="More actions"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
@@ -800,7 +905,8 @@ function FileActionsDropdown({
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon="chevron_down"
|
||||
// Matches the plain row menu, so the actions column reads the same for file and text rows
|
||||
icon="ellipsis_vertical"
|
||||
title="Select form data type"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
@@ -808,7 +914,7 @@ function FileActionsDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function emptyPair(): PairWithId {
|
||||
function emptyPair(): EditablePairWithId {
|
||||
return ensurePairId({ enabled: true, name: "", value: "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import type { Pair, PairWithId } from "./PairEditor";
|
||||
|
||||
export function ensurePairId(p: Pair): PairWithId {
|
||||
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
||||
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
||||
if (typeof p.id === "string") {
|
||||
return p as PairWithId;
|
||||
return p as T & PairWithId;
|
||||
}
|
||||
return { ...p, id: p.id ?? generateId() };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKey } from "react-use";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
@@ -15,6 +16,12 @@ export interface ToastProps {
|
||||
action?: (args: { hide: () => void }) => ReactNode;
|
||||
icon?: ShowToastRequest["icon"] | null;
|
||||
color?: ShowToastRequest["color"];
|
||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
||||
// past the default max height
|
||||
dynamicHeight?: boolean;
|
||||
// Hide the close button, for toasts that render their own dismiss action.
|
||||
// Escape still closes the toast
|
||||
hideDismiss?: boolean;
|
||||
}
|
||||
|
||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||
@@ -28,7 +35,47 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
|
||||
warning: "alert_triangle",
|
||||
};
|
||||
|
||||
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {
|
||||
export function Toast({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
timeout,
|
||||
action,
|
||||
icon,
|
||||
color,
|
||||
dynamicHeight,
|
||||
hideDismiss,
|
||||
}: ToastProps) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
const cancelAutoHide = useCallback(() => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
setAutoHideCanceled(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || timeout == null || autoHideCanceled) return;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onCloseRef.current();
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
};
|
||||
}, [autoHideCanceled, open, timeout]);
|
||||
|
||||
useKey(
|
||||
"Escape",
|
||||
() => {
|
||||
@@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||
"border border-border shadow-lg w-100",
|
||||
)}
|
||||
onFocusCapture={cancelAutoHide}
|
||||
onKeyDownCapture={cancelAutoHide}
|
||||
onPointerDownCapture={cancelAutoHide}
|
||||
>
|
||||
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||
hideDismiss ? "pr-3" : "pr-10",
|
||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||
)}
|
||||
>
|
||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{!hideDismiss && (
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeout != null && (
|
||||
{timeout != null && !autoHideCanceled && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<m.div
|
||||
className="bg-surface-highlight h-[3px]"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { showErrorToast } from "../../lib/toast";
|
||||
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
setCommitError(null);
|
||||
try {
|
||||
await commit.mutateAsync({ message });
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setCommitError(String(err));
|
||||
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
try {
|
||||
const r = await commitAndPush.mutateAsync({ message });
|
||||
handlePushResult(r);
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
@@ -206,9 +209,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
layout="horizontal"
|
||||
defaultRatio={0.6}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<div style={style} className="h-full px-4 flex flex-col gap-3">
|
||||
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
|
||||
<SplitLayout
|
||||
className="min-h-0 flex-1"
|
||||
storageKey="commit-vertical"
|
||||
layout="vertical"
|
||||
defaultRatio={0.35}
|
||||
@@ -226,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
/>
|
||||
{externalEntries.find((e) => e.status !== "current") && (
|
||||
<>
|
||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
||||
<Separator className="mt-3 mb-1">Other files</Separator>
|
||||
{externalEntries.map((entry) => (
|
||||
<ExternalTreeNode
|
||||
key={entry.relaPath + entry.status}
|
||||
entry={entry}
|
||||
relaDir={status.data?.relaDir ?? ""}
|
||||
onCheck={checkEntry}
|
||||
/>
|
||||
))}
|
||||
@@ -239,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
@@ -388,15 +396,22 @@ function TreeNodeChildren({
|
||||
|
||||
function ExternalTreeNode({
|
||||
entry,
|
||||
relaDir,
|
||||
onCheck,
|
||||
}: {
|
||||
entry: GitStatusEntry;
|
||||
relaDir: string;
|
||||
onCheck: (entry: GitStatusEntry) => void;
|
||||
}) {
|
||||
if (entry.status === "current") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show paths relative to the sync directory when inside it
|
||||
const displayPath = entry.relaPath.startsWith(`${relaDir}/`)
|
||||
? entry.relaPath.slice(relaDir.length + 1)
|
||||
: entry.relaPath;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
fullWidth
|
||||
@@ -406,7 +421,7 @@ function ExternalTreeNode({
|
||||
title={
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||
<Icon color="secondary" icon="file_code" />
|
||||
<div className="truncate">{entry.relaPath}</div>
|
||||
<div className="truncate">{displayPath}</div>
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 ml-auto bg-transparent w-24 text-center",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
@@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||
import { RadioDropdown } from "../core/RadioDropdown";
|
||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
||||
request: HttpRequest;
|
||||
};
|
||||
|
||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||
|
||||
export function GraphQLEditor(props: Props) {
|
||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||
// key on the request ID as a workaround for now.
|
||||
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}>(() => {
|
||||
// Migrate text bodies to GraphQL format
|
||||
// NOTE: This is how GraphQL used to be stored
|
||||
if ("text" in request.body) {
|
||||
const b = tryParseJson(request.body.text, {});
|
||||
const variables = JSON.stringify(b.variables || undefined, null, 2);
|
||||
return { query: b.query ?? "", variables };
|
||||
}
|
||||
|
||||
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
|
||||
return normalizeGraphQLBody(request.body);
|
||||
}, [extraEditorProps.forceUpdateKey]);
|
||||
|
||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||
const parsedOperationNames = useMemo(
|
||||
() => parseGraphQLOperationNames(currentBody.query),
|
||||
[currentBody.query],
|
||||
);
|
||||
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(query: string) => {
|
||||
setCurrentBody(({ variables }) => {
|
||||
const newBody = { query, variables };
|
||||
setCurrentBody(({ variables, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
|
||||
const handleChangeVariables = useCallback(
|
||||
(variables: string) => {
|
||||
setCurrentBody(({ query }) => {
|
||||
const newBody = { query, variables: variables || undefined };
|
||||
setCurrentBody(({ query, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
[onChange, setCurrentBody],
|
||||
);
|
||||
|
||||
const handleChangeOperationName = useCallback(
|
||||
(operationName: string) => {
|
||||
setCurrentBody(({ query, variables }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
},
|
||||
[onChange, setCurrentBody],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedOperationNames == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
|
||||
const operationName = operationNames[0];
|
||||
if (currentBody.operationName === operationName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentBody(({ query, variables }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
}, [
|
||||
currentBody.operationName,
|
||||
onChange,
|
||||
operationNames,
|
||||
parsedOperationNames,
|
||||
setCurrentBody,
|
||||
]);
|
||||
|
||||
const actions = useMemo<EditorProps["actions"]>(
|
||||
() => [
|
||||
<div key="actions" className="flex flex-row opacity-100! shadow!">
|
||||
<div key="introspection" className="opacity-100!">
|
||||
{schema === undefined ? null /* Initializing */ : (
|
||||
<Dropdown
|
||||
items={[
|
||||
...((schema != null
|
||||
? [
|
||||
{
|
||||
label: "Clear",
|
||||
onSelect: clear,
|
||||
color: "danger",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
{ type: "separator" },
|
||||
]
|
||||
: []) satisfies DropdownItem[]),
|
||||
{
|
||||
hidden: !error,
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Schema introspection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Introspection Failed",
|
||||
size: "sm",
|
||||
id: "introspection-failed",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{error ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await refetch();
|
||||
}}
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
>
|
||||
Retry Request
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
type: "content",
|
||||
},
|
||||
{
|
||||
hidden: schema == null,
|
||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||
leftSlot: <Icon icon="book_open_text" />,
|
||||
onSelect: () => {
|
||||
setGraphqlDocStateAtomValue((v) => ({
|
||||
...v,
|
||||
[request.id]: isDocOpen ? undefined : null,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Introspect Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: refetch,
|
||||
},
|
||||
{ type: "separator", label: "Setting" },
|
||||
{
|
||||
label: "Automatic Introspection",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => {
|
||||
setAutoIntrospectDisabled({
|
||||
...autoIntrospectDisabled,
|
||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
||||
});
|
||||
},
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={
|
||||
autoIntrospectDisabled?.[baseRequest.id]
|
||||
? "check_square_unchecked"
|
||||
: "check_square_checked"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Refetch Schema"
|
||||
isLoading={isLoading}
|
||||
color={error ? "danger" : "default"}
|
||||
forDropdown
|
||||
>
|
||||
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
operationNames.length > 0 ? (
|
||||
<div key="operation" className="opacity-100!">
|
||||
<RadioDropdown
|
||||
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||
onChange={handleChangeOperationName}
|
||||
items={[
|
||||
{ type: "separator", label: "Operation Name" },
|
||||
{
|
||||
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||
},
|
||||
...operationNames.map((operationName) => ({
|
||||
label: operationName,
|
||||
value: operationName,
|
||||
})),
|
||||
] satisfies RadioDropdownItem<string>[]}
|
||||
>
|
||||
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||
<span className="text-text-subtle italic">Not specified</span>
|
||||
) : (
|
||||
currentBody.operationName ?? operationNames[0]
|
||||
)}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
</div>
|
||||
) : null,
|
||||
<div key="introspection" className="opacity-100!">
|
||||
{schema === undefined ? null /* Initializing */ : (
|
||||
<Dropdown
|
||||
items={[
|
||||
...((schema != null
|
||||
? [
|
||||
{
|
||||
label: "Clear",
|
||||
onSelect: clear,
|
||||
color: "danger",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
{ type: "separator" },
|
||||
]
|
||||
: []) satisfies DropdownItem[]),
|
||||
{
|
||||
hidden: !error,
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Schema introspection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Introspection Failed",
|
||||
size: "sm",
|
||||
id: "introspection-failed",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{error ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await refetch();
|
||||
}}
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
>
|
||||
Retry Request
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
type: "content",
|
||||
},
|
||||
{
|
||||
hidden: schema == null,
|
||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||
leftSlot: <Icon icon="book_open_text" />,
|
||||
onSelect: () => {
|
||||
setGraphqlDocStateAtomValue((v) => ({
|
||||
...v,
|
||||
[request.id]: isDocOpen ? undefined : null,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Introspect Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: refetch,
|
||||
},
|
||||
{ type: "separator", label: "Setting" },
|
||||
{
|
||||
label: "Automatic Introspection",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => {
|
||||
setAutoIntrospectDisabled({
|
||||
...autoIntrospectDisabled,
|
||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
||||
});
|
||||
},
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={
|
||||
autoIntrospectDisabled?.[baseRequest.id]
|
||||
? "check_square_unchecked"
|
||||
: "check_square_checked"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Refetch Schema"
|
||||
isLoading={isLoading}
|
||||
color={error ? "danger" : "default"}
|
||||
forDropdown
|
||||
>
|
||||
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
schema,
|
||||
clear,
|
||||
error,
|
||||
currentBody.operationName,
|
||||
handleChangeOperationName,
|
||||
isDocOpen,
|
||||
isLoading,
|
||||
operationNames,
|
||||
refetch,
|
||||
autoIntrospectDisabled,
|
||||
baseRequest.id,
|
||||
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJson(text: string, fallback: unknown) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return fallback;
|
||||
function buildGraphQLBody(body: {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}) {
|
||||
const result: {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
} = {
|
||||
query: body.query,
|
||||
variables: body.variables || undefined,
|
||||
};
|
||||
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,26 @@ import { useEffect, useState } from "react";
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ bodyPath, data }: Props) {
|
||||
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
|
||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
}, [bodyPath, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src} />;
|
||||
|
||||
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
summary.data.fragmentCount === 0 &&
|
||||
!summary.isFetching &&
|
||||
summary.error == null;
|
||||
|
||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
||||
@@ -218,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
<SseSummaryFooter
|
||||
style={style}
|
||||
error={summary.error ? String(summary.error) : null}
|
||||
isLoading={summary.isLoading}
|
||||
isLoading={summary.isLoading && summary.data == null}
|
||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
||||
renderMarkdown={renderMarkdown}
|
||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = { className?: string } & (
|
||||
type Props = { className?: string; mimeType?: string } & (
|
||||
| {
|
||||
bodyPath: string;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Props = { className?: string } & (
|
||||
}
|
||||
);
|
||||
|
||||
export function ImageViewer({ className, ...props }: Props) {
|
||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
||||
const data = "data" in props ? props.data : null;
|
||||
@@ -20,14 +20,14 @@ export function ImageViewer({ className, ...props }: Props) {
|
||||
if (bodyPath != null) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: "image/png" });
|
||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
}, [bodyPath, data, mimeType]);
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -3,7 +3,7 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import "./PdfViewer.css";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
@@ -30,26 +30,32 @@ const options = {
|
||||
export function PdfViewer({ bodyPath, data }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
const [src, setSrc] = useState<string | { data: Uint8Array }>();
|
||||
|
||||
const { width: containerWidth } = useContainerSize(containerRef);
|
||||
|
||||
useEffect(() => {
|
||||
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||
const src = useMemo(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data) {
|
||||
return convertFileSrc(bodyPath);
|
||||
}
|
||||
if (data) {
|
||||
// Create a copy to avoid "Buffer is already detached" errors
|
||||
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
||||
const dataCopy = new Uint8Array(data);
|
||||
setSrc({ data: dataCopy });
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
return { data: new Uint8Array(data) };
|
||||
}
|
||||
return undefined;
|
||||
}, [bodyPath, data]);
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||
setNumPages(nextNumPages);
|
||||
};
|
||||
|
||||
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||
<Document
|
||||
|
||||
@@ -4,23 +4,25 @@ import { useEffect, useState } from "react";
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ bodyPath, data }: Props) {
|
||||
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
|
||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
}, [bodyPath, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src} />;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
websocketRequestsAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
export const allRequestsAtom = atom((get) => [
|
||||
...get(httpRequestsAtom),
|
||||
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
|
||||
export function useAllRequests() {
|
||||
return useAtomValue(allRequestsAtom);
|
||||
}
|
||||
|
||||
const stringArrayEqual = (a: string[], b: string[]) =>
|
||||
a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
// Identity-stable derivations so subscribers don't recompute or re-render when
|
||||
// unrelated request fields change (eg. every debounced edit of a request)
|
||||
export const allRequestIdsAtom = selectAtom(
|
||||
allRequestsAtom,
|
||||
(requests) => requests.map((r) => r.id),
|
||||
stringArrayEqual,
|
||||
);
|
||||
|
||||
export const allRequestUrlsAtom = selectAtom(
|
||||
allRequestsAtom,
|
||||
(requests) => {
|
||||
const urls = new Set<string>();
|
||||
for (const r of requests) {
|
||||
if (r.url) urls.add(r.url);
|
||||
}
|
||||
return Array.from(urls);
|
||||
},
|
||||
stringArrayEqual,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
@@ -22,8 +23,14 @@ export function useGrpc(
|
||||
|
||||
const go = useMutation<void, string>({
|
||||
mutationKey: ["grpc_go", conn?.id],
|
||||
mutationFn: () =>
|
||||
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
|
||||
mutationFn: async () => {
|
||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||
return invokeCmd<void>("cmd_grpc_go", {
|
||||
requestId,
|
||||
environmentId: environment?.id,
|
||||
protoFiles,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
|
||||
@@ -6,21 +6,19 @@ import { useMemo } from "react";
|
||||
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
|
||||
return useMemo(() => getParentFolders(folders, m), [folders, m]);
|
||||
// Key on folderId, not the model itself, so edits to the model (eg. every URL
|
||||
// keystroke replacing the active request) don't produce a new array identity
|
||||
const folderId = m?.folderId ?? null;
|
||||
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
|
||||
}
|
||||
|
||||
function getParentFolders(
|
||||
folders: Folder[],
|
||||
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
): Folder[] {
|
||||
if (currentModel == null) return [];
|
||||
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
|
||||
if (folderId == null) return [];
|
||||
|
||||
const parentFolder = currentModel.folderId
|
||||
? folders.find((f) => f.id === currentModel.folderId)
|
||||
: null;
|
||||
const parentFolder = folders.find((f) => f.id === folderId);
|
||||
if (parentFolder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [parentFolder, ...getParentFolders(folders, parentFolder)];
|
||||
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getResponseBodySseSummary } from "../lib/responseBody";
|
||||
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
|
||||
return useQuery<SseSummary>({
|
||||
enabled: resultKeyPath != null,
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: [
|
||||
"response-body-sse-summary",
|
||||
response.id,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { atom } from "jotai";
|
||||
import type { DropdownItem } from "../components/core/Dropdown";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
/**
|
||||
* A menu opened from somewhere that can't render React.
|
||||
*
|
||||
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
|
||||
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
|
||||
* synchronously, so it can only hand over a position and a list of items. Same shape as
|
||||
* {@link showDialog}.
|
||||
*/
|
||||
export interface ContextMenuInstance {
|
||||
id: string;
|
||||
/** Where to open, in viewport coordinates */
|
||||
triggerPosition: { x: number; y: number };
|
||||
/** The trigger's box, when it has one, so placement can align to its edges */
|
||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||
/** The element it belongs to, so clicking that doesn't read as a click outside */
|
||||
triggerEl?: HTMLElement | null;
|
||||
items: DropdownItem[];
|
||||
}
|
||||
|
||||
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
|
||||
|
||||
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
|
||||
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the menu, or closes it if this one is already open.
|
||||
*
|
||||
* What a trigger wants: pressing it a second time should put the menu away. Same shape as
|
||||
* {@link toggleDialog}.
|
||||
*/
|
||||
export function toggleContextMenu({ id, ...props }: ContextMenuInstance) {
|
||||
if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) {
|
||||
hideContextMenu(id);
|
||||
} else {
|
||||
showContextMenu({ id, ...props });
|
||||
}
|
||||
}
|
||||
|
||||
export function hideContextMenu(id: string) {
|
||||
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Common Accept-Language values.
|
||||
export const acceptLanguages = [
|
||||
"*",
|
||||
"en",
|
||||
"en-US",
|
||||
"en-US,en;q=0.9",
|
||||
"en-GB,en;q=0.9",
|
||||
"de-DE,de;q=0.9,en;q=0.8",
|
||||
"fr-FR,fr;q=0.9,en;q=0.8",
|
||||
"es-ES,es;q=0.9,en;q=0.8",
|
||||
"ru-RU,ru;q=0.9,en;q=0.8",
|
||||
"uk-UA,uk;q=0.9,en;q=0.8",
|
||||
"zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"ja-JP,ja;q=0.9,en;q=0.8",
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
// Common Cache-Control directives (request and response).
|
||||
export const cacheControlDirectives = [
|
||||
"no-cache",
|
||||
"no-store",
|
||||
"no-transform",
|
||||
"max-age=0",
|
||||
"max-age=3600",
|
||||
"max-age=86400",
|
||||
"s-maxage=3600",
|
||||
"must-revalidate",
|
||||
"proxy-revalidate",
|
||||
"stale-while-revalidate=60",
|
||||
"public",
|
||||
"private",
|
||||
"immutable",
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* A suggested value for a header. A plain string is used when the displayed
|
||||
* label and the inserted value are the same (e.g. mime types). The object form
|
||||
* lets us show a short, readable `label` (e.g. "Chrome (Windows)") while
|
||||
* inserting a longer `value` (the full User-Agent string).
|
||||
*/
|
||||
export type HeaderValuePreset = string | { label: string; value: string };
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HeaderValuePreset } from "./headerValuePresets";
|
||||
|
||||
// Common, real-world User-Agent strings. The short `label` is shown in the
|
||||
// dropdown/autocomplete, while the full UA string is what gets inserted.
|
||||
//
|
||||
// Browsers freeze parts of their UA: Chrome reports its minor version as 0.0.0
|
||||
// and Safari reports macOS as 10_15_7, regardless of the actual version.
|
||||
export const userAgents: HeaderValuePreset[] = [
|
||||
{
|
||||
label: "Chrome 151 · Windows 10/11 · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Edge 151 · Windows 10/11 · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0",
|
||||
},
|
||||
{
|
||||
label: "Firefox 153 · Windows 10/11 · x64",
|
||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · macOS",
|
||||
value:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Safari 27 · macOS",
|
||||
value:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · Linux · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Safari 27 · iOS 27 · iPhone",
|
||||
value:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · Android 16 · Pixel 10",
|
||||
value:
|
||||
"Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36",
|
||||
},
|
||||
{ label: "curl 8.21.0 · CLI", value: "curl/8.21.0" },
|
||||
{ label: "Postman 7.56 · API client", value: "PostmanRuntime/7.56.1" },
|
||||
{ label: "Insomnia 13.1 · API client", value: "insomnia/13.1.0" },
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { docFingerprint } from "./docFingerprint";
|
||||
|
||||
const sampled = (text: string) => docFingerprint(text, { exact: false });
|
||||
const exact = (text: string) => docFingerprint(text, { exact: true });
|
||||
|
||||
/** Same length as `text`, with one character changed at `at` */
|
||||
const changeAt = (text: string, at: number) =>
|
||||
`${text.slice(0, at)}${text[at] === "b" ? "c" : "b"}${text.slice(at + 1)}`;
|
||||
|
||||
describe("docFingerprint", () => {
|
||||
test("is stable for the same text", () => {
|
||||
expect(sampled("hello")).toBe(sampled("hello"));
|
||||
expect(exact("hello")).toBe(exact("hello"));
|
||||
});
|
||||
|
||||
test("differs on different short text", () => {
|
||||
expect(sampled("hello")).not.toBe(sampled("world"));
|
||||
});
|
||||
|
||||
test("differs on length alone", () => {
|
||||
expect(sampled("a".repeat(1_000_000))).not.toBe(sampled("a".repeat(1_000_001)));
|
||||
});
|
||||
|
||||
test("hashes small documents in full, so any change is caught", () => {
|
||||
const doc = "a".repeat(100);
|
||||
for (let i = 0; i < doc.length; i++) {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, i)));
|
||||
}
|
||||
});
|
||||
|
||||
describe("sampled", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("notices a change at the start", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, 0)));
|
||||
});
|
||||
|
||||
test("notices a change at the end", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length - 1)));
|
||||
});
|
||||
|
||||
test("notices a change in the middle", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length / 2)));
|
||||
});
|
||||
|
||||
test("misses a same-length change between the samples", () => {
|
||||
// The documented cost of sampling. Read-only editors accept it because the worst a
|
||||
// collision can do there is restore a fold or the cursor to the wrong place.
|
||||
expect(sampled(doc)).toBe(sampled(changeAt(doc, 250_000)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("catches a same-length change anywhere, which is why editable docs use it", () => {
|
||||
for (const at of [0, 250_000, doc.length / 2, 750_000, doc.length - 1]) {
|
||||
expect(exact(doc)).not.toBe(exact(changeAt(doc, at)));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not collide with the sampled fingerprint of the same text", () => {
|
||||
expect(exact(doc)).not.toBe(sampled(doc));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
/** How much of each end and the middle to hash */
|
||||
const SAMPLE_CHARS = 512;
|
||||
|
||||
/**
|
||||
* Identifies a document, so a cached editor state is never restored onto different content.
|
||||
*
|
||||
* Hashing the whole document costs about 4 ms per megabyte and is paid on every editor update
|
||||
* as well as on restore, which is a lot of work for a document nobody can edit.
|
||||
*
|
||||
* `exact` decides how much certainty that buys. Editable documents get a full hash, because a
|
||||
* collision there would restore undo history belonging to other content and let an undo write
|
||||
* nonsense into the body. Read-only documents get a sampled one: they hold the megabyte-sized
|
||||
* responses this exists for, their history can never be applied, and the worst a collision can
|
||||
* do is put a fold or the cursor in the wrong place.
|
||||
*
|
||||
* Sampling still pins the exact length plus three windows, so a colliding pair has to agree on
|
||||
* all four and differ only in between. Documents small enough to hash outright still are.
|
||||
*/
|
||||
export function docFingerprint(text: string, { exact }: { exact: boolean }): string {
|
||||
if (exact || text.length <= SAMPLE_CHARS * 3) {
|
||||
return `${text.length}:${md5(text)}`;
|
||||
}
|
||||
|
||||
const middle = Math.floor((text.length - SAMPLE_CHARS) / 2);
|
||||
return [
|
||||
text.length,
|
||||
md5(text.slice(0, SAMPLE_CHARS)),
|
||||
md5(text.slice(middle, middle + SAMPLE_CHARS)),
|
||||
md5(text.slice(-SAMPLE_CHARS)),
|
||||
].join(":");
|
||||
}
|
||||
@@ -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,246 @@
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import mime from "mime";
|
||||
import { createElement } from "react";
|
||||
import type { DropdownItem } from "../components/core/Dropdown";
|
||||
import type { SniffedValue } from "../components/core/Editor/sniffValue";
|
||||
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
||||
import { copyToClipboard } from "./copy";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { invokeCmd } from "./tauri";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
/**
|
||||
* How the value is written, which is the thing worth knowing about it — that it is base64
|
||||
* explains why it reads as gibberish far better than its length does.
|
||||
*
|
||||
* A value we couldn't identify may still be plainly encoded, so it is checked here too: an
|
||||
* unrecognised format and an unrecognised encoding are different things to be told.
|
||||
*
|
||||
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters of the value.
|
||||
*/
|
||||
export function encodingLabel(head: string, sniffed: SniffedValue | null, chars: number): string {
|
||||
const size = `${chars.toLocaleString()} chars`;
|
||||
if (sniffed?.encoding === "percent") {
|
||||
return `Percent-encoded · ${size}`;
|
||||
}
|
||||
const encoded = sniffed?.encoding === "base64" || isEncodedRun(head, sniffed?.offset ?? 0);
|
||||
return encoded ? `Base64 · ${size}` : size;
|
||||
}
|
||||
|
||||
interface ActionOptions {
|
||||
/** The whole value. A function, so opening a menu never copies megabytes to build it. */
|
||||
value: () => string;
|
||||
sniffed: SniffedValue | null;
|
||||
/** What to copy. The tag copies only what it hides; the viewer copies what it shows. */
|
||||
copyText: () => string;
|
||||
/** Left out inside the viewer itself, where there is nothing further to open */
|
||||
onView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The things you can do with a collapsed value, as menu items.
|
||||
*
|
||||
* Shared so the tag in the editor and the dialog it opens offer the same list, rather than
|
||||
* drifting apart.
|
||||
*/
|
||||
export function largeValueActions({
|
||||
value,
|
||||
sniffed,
|
||||
copyText,
|
||||
onView,
|
||||
}: ActionOptions): DropdownItem[] {
|
||||
const items: DropdownItem[] = [];
|
||||
|
||||
if (onView != null) {
|
||||
items.push({
|
||||
label: sniffed == null ? "View" : `View ${sniffed.label}`,
|
||||
leftSlot: createElement(Icon, { icon: "eye" }),
|
||||
onSelect: onView,
|
||||
});
|
||||
}
|
||||
|
||||
// Two ways to copy a picture, because either can be the one you wanted: the image to paste
|
||||
// somewhere that takes one, or the text to paste back into a request
|
||||
const image = isCopyableImage(sniffed);
|
||||
if (image) {
|
||||
items.push({
|
||||
label: "Copy Image",
|
||||
leftSlot: createElement(Icon, { icon: "copy" }),
|
||||
onSelect: () => copyImage(value(), sniffed, copyText()),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy",
|
||||
// Second of a pair reads as one thing with two options, so it keeps the indent without
|
||||
// repeating the icon above it
|
||||
leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }),
|
||||
onSelect: () => copyToClipboard(copyText()),
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: "Save to File",
|
||||
leftSlot: createElement(Icon, { icon: "download" }),
|
||||
onSelect: () =>
|
||||
fireAndForget(saveValue(value(), sniffed, sniffed?.label.toLowerCase() ?? "value")),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The payload, with the `data:` header taken off. */
|
||||
function payloadOf(text: string, sniffed: SniffedValue): string {
|
||||
return text.slice(sniffed.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload every strict base64 decoder will accept.
|
||||
*
|
||||
* The url-safe alphabet stands for the same bytes, and a ragged final group is dropped rather
|
||||
* than padded — it can only ever be worth a single byte, and both `atob` and the Rust decoder
|
||||
* behind the save command reject one outright.
|
||||
*/
|
||||
function normalizeBase64(payload: string): string {
|
||||
const clean = payload.replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
|
||||
return clean.slice(0, clean.length - (clean.length % 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a collapsed value stands for.
|
||||
*
|
||||
* `atob` rather than `Uint8Array.fromBase64`, which is too new to rely on across the webviews we
|
||||
* ship on. Only ever called for something the user asked to see, never during layout.
|
||||
*/
|
||||
export function decodeValue(text: string, sniffed: SniffedValue): Uint8Array<ArrayBuffer> {
|
||||
const payload = payloadOf(text, sniffed);
|
||||
if (sniffed.encoding === "percent") {
|
||||
return new TextEncoder().encode(decodeURIComponent(payload));
|
||||
}
|
||||
|
||||
const binary = atob(normalizeBase64(payload));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clipboard can take this as a picture rather than as text.
|
||||
*
|
||||
* SVG is left out on purpose: it is markup, so it pastes usefully as text, and rasterising it
|
||||
* would throw away the thing that makes it worth having.
|
||||
*/
|
||||
function isCopyableImage(sniffed: SniffedValue | null): sniffed is SniffedValue {
|
||||
return (
|
||||
sniffed != null && sniffed.mime.startsWith("image/") && !sniffed.mime.startsWith("image/svg")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value as a PNG.
|
||||
*
|
||||
* Not a preference: PNG is the only image format the Clipboard API requires a browser to accept
|
||||
* on write — `text/plain`, `text/html` and `image/png` are the spec's mandatory data types — and
|
||||
* engines reject `image/jpeg` outright. So anything else is rasterised through a canvas, which
|
||||
* costs a decode and an encode but means a JPEG or a WEBP pastes just as readily as a PNG.
|
||||
*
|
||||
* `ClipboardItem.supports()` exists to ask whether a format could be written as it stands, and
|
||||
* skipping the conversion would be worth real time on a large photo. It was tried: WebKit says
|
||||
* no to `image/jpeg`, so the check only ever chose PNG and was removed again.
|
||||
*
|
||||
* The re-encode is lossless, so nothing is degraded, but a photograph lands on the clipboard far
|
||||
* larger than its JPEG. That matters less than it looks: the system pasteboard holds images
|
||||
* uncompressed regardless of what we hand it.
|
||||
*/
|
||||
async function toPngBlob(text: string, sniffed: SniffedValue): Promise<Blob> {
|
||||
const blob = new Blob([decodeValue(text, sniffed)], { type: sniffed.mime });
|
||||
if (sniffed.mime === "image/png") {
|
||||
return blob;
|
||||
}
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx == null) {
|
||||
throw new Error("Could not get a canvas to convert the image");
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(png) => (png == null ? reject(new Error("Could not encode the image")) : resolve(png)),
|
||||
"image/png",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the picture on the clipboard, so it can be pasted anywhere that takes an image.
|
||||
*
|
||||
* Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside
|
||||
* the click that asked for it, and decoding a few megabytes takes longer than that lasts — so
|
||||
* the item is handed the still-pending promise rather than a finished blob.
|
||||
*/
|
||||
export function copyImage(text: string, sniffed: SniffedValue, fallback: string) {
|
||||
if (typeof ClipboardItem === "undefined") {
|
||||
copyToClipboard(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
const png = toPngBlob(text, sniffed);
|
||||
navigator.clipboard
|
||||
.write([new ClipboardItem({ "image/png": png })])
|
||||
.then(() =>
|
||||
showToast({
|
||||
id: "copied",
|
||||
color: "success",
|
||||
icon: "copy",
|
||||
// Says PNG because that is what lands on the clipboard, whatever the value held
|
||||
message: "Copied as PNG",
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
// Anything from a webview that won't take an image to a file we couldn't decode. The
|
||||
// encoded text is always there to fall back on.
|
||||
console.error("Failed to copy image, copying text instead", err);
|
||||
copyToClipboard(fallback);
|
||||
});
|
||||
}
|
||||
|
||||
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a collapsed value to a file the user picks.
|
||||
*
|
||||
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
|
||||
* encode it again would walk megabytes twice for nothing.
|
||||
*/
|
||||
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
||||
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
||||
const filepath = await save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
|
||||
if (filepath == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
const data =
|
||||
sniffed == null
|
||||
? toBase64(new TextEncoder().encode(text))
|
||||
: sniffed.encoding === "base64"
|
||||
? normalizeBase64(payloadOf(text, sniffed))
|
||||
: toBase64(decodeValue(text, sniffed));
|
||||
|
||||
await invokeCmd("cmd_save_base64_to_binary", { filepath, data });
|
||||
showToast({ message: `Saved to ${filepath}` });
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
||||
import {
|
||||
derivePathPlaceholderPairs,
|
||||
extractPathPlaceholders,
|
||||
renamePathPlaceholder,
|
||||
} from "./pathPlaceholders";
|
||||
|
||||
describe("extractPathPlaceholders", () => {
|
||||
test("extracts a single placeholder", () => {
|
||||
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
|
||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePathPlaceholderPairs", () => {
|
||||
const neverRename = () => false;
|
||||
|
||||
test("adds a row for a placeholder with no parameter", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
|
||||
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[parameter],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
|
||||
});
|
||||
|
||||
test("leaves query parameters alone", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
|
||||
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("commitName renames this row's placeholder", () => {
|
||||
const renames: [string, string][] = [];
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/a/:x/b/:y",
|
||||
[],
|
||||
(oldName, newName) => {
|
||||
renames.push([oldName, newName]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
urlParameterPairs[1]?.commitName?.(":z");
|
||||
expect(renames).toEqual([[":y", ":z"]]);
|
||||
});
|
||||
|
||||
test("drops empty parameters", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users",
|
||||
[
|
||||
{ name: "", value: "", enabled: true, id: "p1" },
|
||||
{ name: "q", value: "", enabled: true, id: "p2" },
|
||||
],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
|
||||
});
|
||||
|
||||
test("collapses a placeholder that appears twice into one row", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
|
||||
});
|
||||
|
||||
test("gives a derived row the same id every time, so re-deriving is stable", () => {
|
||||
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
|
||||
});
|
||||
|
||||
test("derived row ids avoid colliding with a persisted derived id", () => {
|
||||
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
|
||||
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
|
||||
// and the replacement placeholder's row must not collide with it.
|
||||
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
|
||||
const ids = urlParameterPairs.map((p) => p.id);
|
||||
expect(new Set(ids).size).toEqual(ids.length);
|
||||
});
|
||||
|
||||
test("keeps a derived row's id stable across a rename", () => {
|
||||
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
|
||||
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
|
||||
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
|
||||
before.urlParameterPairs.map((p) => p.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("keys off the placeholder names", () => {
|
||||
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
|
||||
":x,:y",
|
||||
);
|
||||
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renamePathPlaceholder", () => {
|
||||
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
|
||||
url,
|
||||
urlParameters,
|
||||
});
|
||||
|
||||
test("renames the placeholder in the URL", () => {
|
||||
expect(
|
||||
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
|
||||
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
|
||||
});
|
||||
|
||||
test("carries the parameter value over to the new name", () => {
|
||||
const patch = renamePathPlaceholder(
|
||||
model("/pets/:petId", [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":petId", value: "42" },
|
||||
]),
|
||||
":petId",
|
||||
":animalId",
|
||||
);
|
||||
expect(patch).toEqual({
|
||||
url: "/pets/:animalId",
|
||||
urlParameters: [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":animalId", value: "42" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("renames every occurrence of a repeated placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:key",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds a missing leading colon", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
|
||||
"/pets/:animalId",
|
||||
);
|
||||
});
|
||||
|
||||
test("renames a placeholder followed by a literal colon", () => {
|
||||
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
|
||||
"/tasks/:taskId:cancel",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rename a placeholder the new name is a prefix of", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:idx",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not touch a same-named segment that isn't a placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
|
||||
"/id/:key?x=:id",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats regex characters in the old name literally", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:iXd",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
|
||||
"rejects the unusable name %j",
|
||||
(name) => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a name already used by another placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
|
||||
});
|
||||
|
||||
test("allows renaming a placeholder to itself", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
|
||||
"/pets/:petId",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects renaming a placeholder that isn't in the URL", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||
import type { EditablePair } from "../components/core/PairEditor";
|
||||
|
||||
/**
|
||||
* Extract `:name`-style path placeholders from a URL string.
|
||||
*
|
||||
@@ -12,3 +15,88 @@
|
||||
export function extractPathPlaceholders(url: string): string[] {
|
||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
|
||||
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
|
||||
* in the URL still gets a single row.
|
||||
*
|
||||
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
|
||||
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
|
||||
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
|
||||
*
|
||||
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
|
||||
* editor so derived rows appear and disappear along with the URL.
|
||||
*/
|
||||
export function derivePathPlaceholderPairs(
|
||||
url: string,
|
||||
urlParameters: HttpUrlParameter[],
|
||||
renamePlaceholder: (oldName: string, newName: string) => boolean,
|
||||
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
|
||||
const placeholderNames = extractPathPlaceholders(url);
|
||||
const commitNameFor = (oldName: string) => (newName: string) =>
|
||||
renamePlaceholder(oldName, newName);
|
||||
|
||||
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
|
||||
// persisted model.
|
||||
const urlParameterPairs: EditablePair[] = urlParameters
|
||||
.filter((p) => p.name || p.value)
|
||||
.map((p) =>
|
||||
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
|
||||
);
|
||||
|
||||
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
|
||||
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
|
||||
// changed id remounts the row and drops the user's focus.
|
||||
//
|
||||
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
|
||||
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
|
||||
// so a new placeholder at that position can't collide with it.
|
||||
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
|
||||
const uniquePlaceholderNames = [...new Set(placeholderNames)];
|
||||
for (const [index, name] of uniquePlaceholderNames.entries()) {
|
||||
if (urlParameterPairs.some((p) => p.name === name)) continue;
|
||||
|
||||
let id = `path-placeholder:${index}`;
|
||||
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
|
||||
takenIds.add(id);
|
||||
|
||||
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
|
||||
}
|
||||
|
||||
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
|
||||
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
|
||||
* together, or the value detaches from the placeholder.
|
||||
*
|
||||
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
|
||||
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
|
||||
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
|
||||
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
|
||||
* selects all of its text and typing over it is the natural way to rename.
|
||||
*/
|
||||
export function renamePathPlaceholder(
|
||||
model: { url: string; urlParameters: HttpUrlParameter[] },
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): { url: string; urlParameters: HttpUrlParameter[] } | null {
|
||||
const name = newName.startsWith(":") ? newName : `:${newName}`;
|
||||
if (!/^:[^/?#:\s]+$/.test(name)) return null;
|
||||
|
||||
const placeholderNames = extractPathPlaceholders(model.url);
|
||||
if (!placeholderNames.includes(oldName)) return null;
|
||||
if (name !== oldName && placeholderNames.includes(name)) return null;
|
||||
|
||||
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
|
||||
return {
|
||||
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
|
||||
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
} from "./model_util";
|
||||
import { convertRequestBody } from "./requestBodyConversion";
|
||||
|
||||
describe("convertRequestBody", () => {
|
||||
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: {
|
||||
text: JSON.stringify({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
operationName: "GetUser",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts GraphQL bodies to JSON text", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_GRAPHQL,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{ "id": "123" }',
|
||||
operationName: "GetUser",
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify(
|
||||
{
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_OTHER,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "basic", value: "aaa" },
|
||||
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
|
||||
{ enabled: false, name: "disabled", value: "hidden" },
|
||||
{ enabled: true, name: "", value: "unnamed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to JSON text for JSON bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "tag", value: "one" },
|
||||
{ enabled: true, name: "tag", value: "two" },
|
||||
{ enabled: true, name: "limit", value: "10" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves text when converting to form bodies cannot build form pairs", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_XML,
|
||||
toBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
body: { text: "a=1&b=two+words" },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "a=1&b=two+words",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON text that is not a GraphQL envelope", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text: JSON.stringify({ name: "Yaak" }) },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ name: "Yaak" }),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
|
||||
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text },
|
||||
});
|
||||
|
||||
expect(body).toEqual({ text });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves text when converting to binary cannot build a file body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_BINARY,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: '{ "name": "Yaak" }',
|
||||
});
|
||||
});
|
||||
|
||||
test("clears body when converting to no body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_NONE,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
} from "./model_util";
|
||||
|
||||
type Body = HttpRequest["body"];
|
||||
type BodyType = HttpRequest["bodyType"];
|
||||
type GraphQLBody = {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
export function convertRequestBody({
|
||||
body,
|
||||
fromBodyType,
|
||||
toBodyType,
|
||||
}: {
|
||||
body: Body;
|
||||
fromBodyType: BodyType;
|
||||
toBodyType: BodyType;
|
||||
}): Body {
|
||||
if (toBodyType === BODY_TYPE_NONE) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_GRAPHQL) {
|
||||
return toGraphQLBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||
return toFormBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_BINARY) {
|
||||
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
|
||||
}
|
||||
|
||||
return toTextBody(body, fromBodyType, toBodyType) ?? body;
|
||||
}
|
||||
|
||||
export function normalizeGraphQLBody(body: Body): GraphQLBody {
|
||||
return toGraphQLBody(body) ?? { query: "", variables: undefined };
|
||||
}
|
||||
|
||||
function toGraphQLBody(body: Body): GraphQLBody | null {
|
||||
if (typeof body.query === "string") {
|
||||
const result: GraphQLBody = {
|
||||
query: body.query,
|
||||
variables: typeof body.variables === "string" ? body.variables : undefined,
|
||||
};
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.text);
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed.query !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = parsed.query;
|
||||
const variables =
|
||||
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
|
||||
|
||||
const result: GraphQLBody = { query, variables };
|
||||
if (typeof parsed.operationName === "string") {
|
||||
result.operationName = parsed.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return { query: body.text, variables: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toFormBody(body: Body): Body | null {
|
||||
if (Array.isArray(body.form)) {
|
||||
return {
|
||||
form: body.form.map((p) => ({
|
||||
enabled: p.enabled !== false,
|
||||
name: typeof p.name === "string" ? p.name : "",
|
||||
value: stringifyFormValue(p.value ?? p.file),
|
||||
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
|
||||
filename: typeof p.filename === "string" ? p.filename : undefined,
|
||||
file: typeof p.file === "string" ? p.file : undefined,
|
||||
id: typeof p.id === "string" ? p.id : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
|
||||
const sendJsonComments =
|
||||
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
return { text: body.text, ...sendJsonComments };
|
||||
}
|
||||
|
||||
if (Array.isArray(body.form)) {
|
||||
if (toBodyType === BODY_TYPE_JSON) {
|
||||
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
|
||||
}
|
||||
|
||||
return { text: formBodyToUrlEncodedText(body.form) };
|
||||
}
|
||||
|
||||
if (typeof body.query === "string") {
|
||||
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
|
||||
const value: Record<string, unknown> = { query: body.query };
|
||||
if (typeof body.variables === "string" && body.variables.trim() !== "") {
|
||||
value.variables = parseJson(body.variables) ?? body.variables;
|
||||
}
|
||||
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||
value.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return { text: JSON.stringify(value, null, 2) };
|
||||
}
|
||||
|
||||
return { text: body.query };
|
||||
}
|
||||
|
||||
if (typeof body.filePath === "string") {
|
||||
return { text: body.filePath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formBodyToUrlEncodedText(form: unknown[]): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
params.append(pair.name, stringifyFormValue(pair.value));
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function formBodyToObject(form: unknown[]) {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
|
||||
const value = stringifyFormValue(pair.value);
|
||||
if (pair.name in result) {
|
||||
const existing = result[pair.name];
|
||||
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
||||
} else {
|
||||
result[pair.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringifyFormValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -45,9 +45,11 @@ type TauriCmd =
|
||||
| "cmd_plugin_init_errors"
|
||||
| "cmd_reload_plugins"
|
||||
| "cmd_render_template"
|
||||
| "cmd_save_base64_to_binary"
|
||||
| "cmd_save_response"
|
||||
| "cmd_secure_template"
|
||||
| "cmd_send_ephemeral_request"
|
||||
| "cmd_send_feedback"
|
||||
| "cmd_send_http_request"
|
||||
| "cmd_template_function_summaries"
|
||||
| "cmd_template_function_config"
|
||||
|
||||
@@ -28,15 +28,17 @@ export function showToast({
|
||||
|
||||
setTimeout(() => {
|
||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||
if (timeout != null) {
|
||||
setTimeout(() => hideToast(newToast), timeout);
|
||||
}
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@shopify/lang-jsonc": "^1.0.1",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
@@ -57,7 +57,7 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
"nanoid": "^5.0.9",
|
||||
"nanoid": "^5.1.16",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { queryClient } from "../lib/queryClient";
|
||||
|
||||
const Toasts = lazy(() => import("../components/Toasts").then((m) => ({ default: m.Toasts })));
|
||||
const Dialogs = lazy(() => import("../components/Dialogs").then((m) => ({ default: m.Dialogs })));
|
||||
const ContextMenus = lazy(() =>
|
||||
import("../components/ContextMenus").then((m) => ({ default: m.ContextMenus })),
|
||||
);
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RouteComponent,
|
||||
@@ -29,6 +32,7 @@ function RouteComponent() {
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
|
||||
+51
-11
@@ -1,32 +1,49 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { type as osType } from "@tauri-apps/plugin-os";
|
||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||
import type { Appearance } from "@yaakapp-internal/theme";
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
getCSSAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
subscribeToPreferredAppearanceChange,
|
||||
subscribeToSystemAppearanceChange,
|
||||
} from "@yaakapp-internal/theme";
|
||||
import { getSettings } from "./lib/settings";
|
||||
import { getResolvedTheme } from "./lib/themes";
|
||||
|
||||
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
||||
// a good appearance guess so we're not waiting too long
|
||||
let preferredAppearance: Appearance = getCSSAppearance();
|
||||
subscribeToPreferredAppearance(async (a) => {
|
||||
let preferredAppearance: Appearance = getInitialAppearance();
|
||||
let linuxSystemAppearanceAvailable =
|
||||
osType() === "linux" && window.__YAAK_INITIAL_APPEARANCE_SOURCE__ === "linux-system";
|
||||
let configureThemeGeneration = 0;
|
||||
let windowShown = false;
|
||||
|
||||
configureThemeAndShow().catch((err) => console.log("Failed to configure theme", err));
|
||||
|
||||
subscribeToPreferredAppearanceChange(async (a) => {
|
||||
if (linuxSystemAppearanceAvailable) return;
|
||||
preferredAppearance = a;
|
||||
await configureTheme();
|
||||
await configureThemeAndShow();
|
||||
});
|
||||
|
||||
configureTheme().then(
|
||||
async () => {
|
||||
subscribeToSystemAppearanceChange(async (a) => {
|
||||
linuxSystemAppearanceAvailable = true;
|
||||
preferredAppearance = a;
|
||||
await configureThemeAndShow();
|
||||
});
|
||||
|
||||
async function configureThemeAndShow() {
|
||||
const applied = await configureTheme();
|
||||
if (applied && !windowShown) {
|
||||
windowShown = true;
|
||||
// To prevent theme flashing, the backend hides new windows by default, so we
|
||||
// need to show it here, after configuring the theme for the first time.
|
||||
await getCurrentWebviewWindow().show();
|
||||
},
|
||||
(err) => console.log("Failed to configure theme", err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for settings changes, the re-compute theme
|
||||
listen<ModelPayload>("model_write", async (event) => {
|
||||
@@ -34,10 +51,11 @@ listen<ModelPayload>("model_write", async (event) => {
|
||||
|
||||
const model = event.payload.model.model;
|
||||
if (model !== "settings" && model !== "plugin") return;
|
||||
await configureTheme();
|
||||
await configureThemeAndShow();
|
||||
}).catch(console.error);
|
||||
|
||||
async function configureTheme() {
|
||||
async function configureTheme(): Promise<boolean> {
|
||||
const generation = ++configureThemeGeneration;
|
||||
const settings = await getSettings();
|
||||
const theme = await getResolvedTheme(
|
||||
preferredAppearance,
|
||||
@@ -45,8 +63,30 @@ async function configureTheme() {
|
||||
settings.themeLight,
|
||||
settings.themeDark,
|
||||
);
|
||||
|
||||
if (generation !== configureThemeGeneration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
applyThemeToDocument(theme.active);
|
||||
if (theme.active.base.surface != null) {
|
||||
setWindowTheme(theme.active.base.surface);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getInitialAppearance(): Appearance {
|
||||
const initialAppearance = window.__YAAK_INITIAL_APPEARANCE__;
|
||||
if (initialAppearance === "dark" || initialAppearance === "light") {
|
||||
return initialAppearance;
|
||||
}
|
||||
return getCSSAppearance();
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__YAAK_INITIAL_APPEARANCE__?: Appearance;
|
||||
__YAAK_INITIAL_APPEARANCE_SOURCE__?: "settings" | "linux-system";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c";
|
||||
const KEYRING_USER: &str = "yaak";
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_REQUEST_BYTES: usize = 16 * 1024;
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
@@ -209,35 +210,71 @@ async fn receive_oauth_code(
|
||||
expected_state: &str,
|
||||
app_base_url: &str,
|
||||
) -> CommandResult<String> {
|
||||
// Browsers speculatively open extra connections that may never carry a
|
||||
// request. Handle each connection concurrently so an idle socket can't
|
||||
// block the one carrying the real callback.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<CommandResult<String>>(1);
|
||||
|
||||
loop {
|
||||
let (mut stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
|
||||
|
||||
match parse_callback_request(&mut stream).await {
|
||||
Ok((state, code)) => {
|
||||
if state != expected_state {
|
||||
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let success_redirect = format!("{app_base_url}/login/oauth/success");
|
||||
write_redirect(&mut stream, &success_redirect)
|
||||
.await
|
||||
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?;
|
||||
return Ok(code);
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (stream, _) = accepted
|
||||
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
|
||||
tokio::spawn(handle_callback_connection(
|
||||
stream,
|
||||
expected_state.to_string(),
|
||||
app_base_url.to_string(),
|
||||
tx.clone(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = write_bad_request(&mut stream, &error).await;
|
||||
if error.starts_with("OAuth provider returned error:") {
|
||||
return Err(error);
|
||||
result = rx.recv() => {
|
||||
if let Some(result) = result {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_callback_connection(
|
||||
mut stream: TcpStream,
|
||||
expected_state: String,
|
||||
app_base_url: String,
|
||||
tx: tokio::sync::mpsc::Sender<CommandResult<String>>,
|
||||
) {
|
||||
let parsed = match tokio::time::timeout(
|
||||
CALLBACK_READ_TIMEOUT,
|
||||
parse_callback_request(&mut stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => return, // Idle speculative connection; drop it
|
||||
};
|
||||
|
||||
match parsed {
|
||||
Ok((state, code)) => {
|
||||
if state != expected_state {
|
||||
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let success_redirect = format!("{app_base_url}/login/oauth/success");
|
||||
let result = match write_redirect(&mut stream, &success_redirect).await {
|
||||
Ok(()) => Ok(code),
|
||||
Err(e) => Err(format!("Failed responding to OAuth callback: {e}")),
|
||||
};
|
||||
let _ = tx.send(result).await;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = write_bad_request(&mut stream, &error).await;
|
||||
if error.starts_with("OAuth provider returned error:") {
|
||||
let _ = tx.send(Err(error)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
|
||||
let target = read_http_target(stream).await?;
|
||||
if !target.starts_with("/oauth/callback") {
|
||||
@@ -488,6 +525,37 @@ mod tests {
|
||||
assert!(err.contains("User denied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receive_oauth_code_ignores_idle_speculative_connections() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
receive_oauth_code(listener, "expected-state", "http://localhost:9444").await
|
||||
});
|
||||
|
||||
// Browsers preconnect sockets that never carry a request; these must
|
||||
// not block the connection carrying the real callback.
|
||||
let _idle1 = TcpStream::connect(addr).await.expect("connect idle 1");
|
||||
let _idle2 = TcpStream::connect(addr).await.expect("connect idle 2");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||
client
|
||||
.write_all(
|
||||
b"GET /oauth/callback?code=abc123&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.expect("write");
|
||||
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(2), server)
|
||||
.await
|
||||
.expect("idle connections must not block the real callback")
|
||||
.expect("join")
|
||||
.expect("should return code");
|
||||
assert_eq!(code, "abc123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receive_oauth_code_fails_fast_on_provider_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
|
||||
@@ -855,8 +855,6 @@ exports.plugin = {
|
||||
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
|
||||
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
|
||||
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
|
||||
assert!(
|
||||
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
|
||||
);
|
||||
assert!(metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,11 +470,7 @@ async fn build_plugin_reply(
|
||||
}
|
||||
};
|
||||
|
||||
let names = cookie_jar
|
||||
.cookies
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
let names = cookie_jar.cookies.into_iter().map(|c| c.name).collect();
|
||||
|
||||
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||
names,
|
||||
|
||||
@@ -13,6 +13,8 @@ crate-type = ["staticlib", "cdylib", "lib"]
|
||||
[features]
|
||||
cargo-clippy = []
|
||||
default = []
|
||||
cef = ["tauri/cef", "tauri-plugin-dialog/xdg-portal"]
|
||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
|
||||
updater = []
|
||||
license = ["yaak-license"]
|
||||
|
||||
@@ -22,7 +24,11 @@ tauri-build = { version = "2.6.1", features = [] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
|
||||
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1" # For writing values the editor only ever held encoded
|
||||
charset = "0.1.5"
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
cookie = "0.18.1"
|
||||
@@ -59,7 +65,6 @@ tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-shell = { workspace = true }
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio-stream = "0.1.17"
|
||||
@@ -83,6 +88,7 @@ yaak-mac-window = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
yaak-system-appearance = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use log::{debug, warn};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Runtime, is_dev};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FeedbackPayload {
|
||||
feature: String,
|
||||
text: String,
|
||||
app_version: String,
|
||||
os: String,
|
||||
}
|
||||
|
||||
/// Send explicit user feedback for a feature. Fire-and-forget: errors are
|
||||
/// logged and swallowed so a failed send never surfaces to the user.
|
||||
pub async fn send_feedback<R: Runtime>(app_handle: &AppHandle<R>, feature: String, text: String) {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let payload = FeedbackPayload {
|
||||
feature,
|
||||
text,
|
||||
app_version: app_version.clone(),
|
||||
os: get_os_str().to_string(),
|
||||
};
|
||||
|
||||
let client = match yaak_api_client(ApiClientKind::App, &app_version) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("Failed to build feedback client: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let url = build_url("/app-feedback");
|
||||
debug!(
|
||||
"Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}",
|
||||
payload.feature,
|
||||
payload.app_version,
|
||||
payload.os,
|
||||
payload.text.len()
|
||||
);
|
||||
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
debug!("Sent feature feedback with status {status}");
|
||||
} else {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response body: {e:?}>"));
|
||||
warn!("Failed to send feature feedback with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to send feature feedback: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(path: &str) -> String {
|
||||
if is_dev() {
|
||||
format!("http://localhost:9444/api/v1{path}")
|
||||
} else {
|
||||
format!("https://api.yaak.app/api/v1{path}")
|
||||
}
|
||||
}
|
||||
@@ -32,18 +32,37 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
|
||||
let repo_dir = dir.to_path_buf();
|
||||
let workdir = paths.workdir;
|
||||
let gitdir = paths.gitdir;
|
||||
let commondir = paths.commondir;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
|
||||
let mut watcher = notify::recommended_watcher(tx)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
|
||||
|
||||
// Watch only the directory Yaak syncs to, not the whole worktree — the
|
||||
// containing repo may be huge and busy (e.g. a sync dir inside a monorepo),
|
||||
// and watching it all burns CPU re-checking status for unrelated changes
|
||||
watcher
|
||||
.watch(&workdir, notify::RecursiveMode::Recursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
|
||||
if gitdir != workdir {
|
||||
.watch(&repo_dir, notify::RecursiveMode::Recursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
|
||||
|
||||
// Watch the git metadata that affects branch/status info: the top-level
|
||||
// gitdir files (HEAD, index) and refs. Not the whole gitdir, since
|
||||
// .git/objects churns constantly during fetches and gc. Refs and
|
||||
// packed-refs live in the common dir, which only differs from the gitdir
|
||||
// for linked worktrees
|
||||
watcher
|
||||
.watch(&gitdir, notify::RecursiveMode::NonRecursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
||||
if commondir != gitdir {
|
||||
watcher
|
||||
.watch(&gitdir, notify::RecursiveMode::Recursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
||||
.watch(&commondir, notify::RecursiveMode::NonRecursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git common dir: {e}")))?;
|
||||
}
|
||||
let refs_dir = commondir.join("refs");
|
||||
if refs_dir.exists() {
|
||||
watcher
|
||||
.watch(&refs_dir, notify::RecursiveMode::Recursive)
|
||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git refs: {e}")))?;
|
||||
}
|
||||
|
||||
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
|
||||
@@ -70,6 +89,7 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
|
||||
&repo_dir,
|
||||
&workdir,
|
||||
&gitdir,
|
||||
&commondir,
|
||||
&channel,
|
||||
).await;
|
||||
}
|
||||
@@ -98,9 +118,10 @@ async fn handle_git_watch_event(
|
||||
repo_dir: &Path,
|
||||
workdir: &Path,
|
||||
gitdir: &Path,
|
||||
commondir: &Path,
|
||||
channel: &Channel<GitWorktreeStatus>,
|
||||
) {
|
||||
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
|
||||
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,7 +132,7 @@ async fn handle_git_watch_event(
|
||||
loop {
|
||||
select! {
|
||||
Some(event_res) = async_rx.recv() => {
|
||||
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
|
||||
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir);
|
||||
}
|
||||
_ = &mut settle_window => {
|
||||
break;
|
||||
@@ -127,6 +148,7 @@ fn is_relevant_git_watch_event(
|
||||
repo_dir: &Path,
|
||||
workdir: &Path,
|
||||
gitdir: &Path,
|
||||
commondir: &Path,
|
||||
) -> bool {
|
||||
let event = match event_res {
|
||||
Ok(event) => event,
|
||||
@@ -137,7 +159,7 @@ fn is_relevant_git_watch_event(
|
||||
};
|
||||
|
||||
for path in event.paths {
|
||||
if path.strip_prefix(gitdir).is_ok() {
|
||||
if path.strip_prefix(gitdir).is_ok() || path.strip_prefix(commondir).is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_log::fern::colors::ColoredLevelConfig;
|
||||
use tauri_plugin_log::{Builder, Target, TargetKind, log};
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
@@ -66,6 +65,7 @@ use yaak_tls::find_client_certificate;
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
mod git_ext;
|
||||
mod git_watcher;
|
||||
mod grpc;
|
||||
@@ -83,6 +83,14 @@ mod uri_scheme;
|
||||
mod window_menu;
|
||||
mod ws_ext;
|
||||
|
||||
#[cfg(not(any(feature = "cef", feature = "wry")))]
|
||||
compile_error!("Enable one Tauri runtime feature: `cef` or `wry`.");
|
||||
|
||||
#[cfg(feature = "cef")]
|
||||
type TauriRuntime = tauri::Cef;
|
||||
#[cfg(all(not(feature = "cef"), feature = "wry"))]
|
||||
type TauriRuntime = tauri::Wry;
|
||||
|
||||
fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
|
||||
#[allow(unused_variables)]
|
||||
let menu = window_menu::app_menu(win.app_handle())?;
|
||||
@@ -151,6 +159,22 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_appearance_script<R: Runtime>(app_handle: &AppHandle<R>) -> Option<String> {
|
||||
use yaak_system_appearance::{Appearance, InitialAppearanceSource};
|
||||
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (appearance, source) = match settings.appearance.as_str() {
|
||||
"dark" => (Appearance::Dark, InitialAppearanceSource::Settings),
|
||||
"light" => (Appearance::Light, InitialAppearanceSource::Settings),
|
||||
_ => (
|
||||
yaak_system_appearance::system_appearance()?,
|
||||
InitialAppearanceSource::LinuxSystem,
|
||||
),
|
||||
};
|
||||
|
||||
Some(yaak_system_appearance::initialization_script(appearance, source))
|
||||
}
|
||||
|
||||
/// Extension trait for easily creating a PluginContext from a WebviewWindow
|
||||
pub trait PluginContextExt<R: Runtime> {
|
||||
fn plugin_context(&self) -> PluginContext;
|
||||
@@ -178,7 +202,7 @@ struct AppMetaData {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
||||
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||
let vendored_plugin_dir =
|
||||
@@ -269,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
text: String,
|
||||
) -> YaakResult<()> {
|
||||
feedback::send_feedback(&app_handle, feature, text).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
@@ -963,7 +997,7 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
mut request: HttpRequest,
|
||||
environment_id: Option<&str>,
|
||||
cookie_jar_id: Option<&str>,
|
||||
window: WebviewWindow,
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
) -> YaakResult<HttpResponse> {
|
||||
let response = HttpResponse::default();
|
||||
@@ -1400,6 +1434,30 @@ async fn cmd_export_data<R: Runtime>(
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
|
||||
/// data directory, and widening them so it could write anywhere would be a poor trade in an app
|
||||
/// whose whole job is rendering responses from servers it doesn't control.
|
||||
///
|
||||
/// Base64 in rather than bytes for two reasons. A `Vec<u8>` crosses the IPC boundary as a JSON
|
||||
/// array of numbers, several times the size of the thing being saved. And the callers that need
|
||||
/// this — values the editor collapsed — are holding base64 already, so passing it through
|
||||
/// untouched means the save never decodes megabytes on the main thread.
|
||||
#[tauri::command]
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
filepath: &str,
|
||||
data: &str,
|
||||
) -> YaakResult<()> {
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.map_err(|e| GenericError(format!("Data is not valid base64: {e}")))?;
|
||||
fs::write(filepath, bytes).map_err(|e| GenericError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_save_response<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -1589,20 +1647,22 @@ async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_new_child_window(
|
||||
parent_window: WebviewWindow,
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
label: &str,
|
||||
title: &str,
|
||||
inner_size: (f64, f64),
|
||||
) -> YaakResult<()> {
|
||||
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
|
||||
let initialization_script = initial_appearance_script(&parent_window.app_handle());
|
||||
let win = yaak_window::window::create_child_window(
|
||||
&parent_window,
|
||||
url,
|
||||
label,
|
||||
title,
|
||||
inner_size,
|
||||
initialization_script,
|
||||
use_native_titlebar,
|
||||
)?;
|
||||
setup_window_menu(&win)?;
|
||||
@@ -1610,9 +1670,15 @@ async fn cmd_new_child_window(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> YaakResult<()> {
|
||||
async fn cmd_new_main_window<R: Runtime>(app_handle: AppHandle<R>, url: &str) -> YaakResult<()> {
|
||||
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
||||
let win = yaak_window::window::create_main_window(&app_handle, url, use_native_titlebar)?;
|
||||
let initialization_script = initial_appearance_script(&app_handle);
|
||||
let win = yaak_window::window::create_main_window(
|
||||
&app_handle,
|
||||
url,
|
||||
initialization_script,
|
||||
use_native_titlebar,
|
||||
)?;
|
||||
setup_window_menu(&win)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1632,8 +1698,17 @@ async fn cmd_check_for_updates<R: Runtime>(
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
#[cfg_attr(feature = "cef", tauri::cef_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default().plugin(
|
||||
// GUI apps launched via Finder/launchd inherit a 256 open-file soft limit on macOS
|
||||
// (1024 on most Linux desktops). SQLite WAL connections hold ~3 fds each, so raise
|
||||
// the limit toward the hard cap before opening any DB pools.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
if let Err(e) = rlimit::increase_nofile_limit(10240) {
|
||||
eprintln!("Failed to raise open-file limit: {e}");
|
||||
}
|
||||
|
||||
let mut builder = tauri::Builder::<TauriRuntime>::default().plugin(
|
||||
Builder::default()
|
||||
.targets([
|
||||
Target::new(TargetKind::Stdout),
|
||||
@@ -1677,13 +1752,6 @@ pub fn run() {
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
// Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart
|
||||
// We could* make this work if we toggled them in the frontend before the window closes, but, this is nicer.
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::new()
|
||||
.with_state_flags(StateFlags::all() - StateFlags::DECORATIONS)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -1714,6 +1782,10 @@ pub fn run() {
|
||||
app.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||
let app_id = app.config().identifier.to_string();
|
||||
app.manage(yaak_crypto::manager::EncryptionManager::new(query_manager, app_id));
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(state) = yaak_system_appearance::watch(app.app_handle().clone()) {
|
||||
app.manage(state);
|
||||
}
|
||||
|
||||
{
|
||||
let app_handle = app.app_handle().clone();
|
||||
@@ -1790,6 +1862,7 @@ pub fn run() {
|
||||
cmd_delete_send_history,
|
||||
cmd_dismiss_notification,
|
||||
cmd_export_data,
|
||||
cmd_send_feedback,
|
||||
cmd_http_request_body,
|
||||
cmd_http_response_body,
|
||||
cmd_format_json,
|
||||
@@ -1814,6 +1887,7 @@ pub fn run() {
|
||||
cmd_reload_plugins,
|
||||
cmd_render_template,
|
||||
cmd_restart,
|
||||
cmd_save_base64_to_binary,
|
||||
cmd_save_response,
|
||||
cmd_send_ephemeral_request,
|
||||
cmd_send_http_request,
|
||||
@@ -1902,9 +1976,11 @@ pub fn run() {
|
||||
match event {
|
||||
RunEvent::Ready => {
|
||||
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
||||
let initialization_script = initial_appearance_script(app_handle);
|
||||
if let Ok(win) = yaak_window::window::create_main_window(
|
||||
app_handle,
|
||||
"/",
|
||||
initialization_script,
|
||||
use_native_titlebar,
|
||||
) {
|
||||
let _ = setup_window_menu(&win);
|
||||
@@ -1925,6 +2001,13 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(state) =
|
||||
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||
{
|
||||
yaak_system_appearance::emit_change(app_handle, &state);
|
||||
}
|
||||
|
||||
if cfg!(feature = "updater") {
|
||||
// Run update check whenever the window is focused
|
||||
let w = app_handle.get_webview_window(&label).unwrap();
|
||||
@@ -1959,13 +2042,6 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::CloseRequested { .. }, .. } => {
|
||||
if let Err(e) = app_handle.save_window_state(StateFlags::all()) {
|
||||
warn!("Failed to save window state {e:?}");
|
||||
} else {
|
||||
info!("Saved window state");
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,20 +194,31 @@ pub(crate) fn models_delete<R: Runtime>(
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
|
||||
// Fetch the model fresh from the DB so the duplicate doesn't come from
|
||||
// a stale frontend snapshot
|
||||
let id = match model_type.as_str() {
|
||||
"environment" => {
|
||||
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
|
||||
}
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
|
||||
"grpc_request" => {
|
||||
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
|
||||
}
|
||||
"http_request" => {
|
||||
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
|
||||
}
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
|
||||
@@ -28,7 +28,7 @@ use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
};
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
@@ -315,6 +315,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.await
|
||||
.expect("Failed to start plugin runtime");
|
||||
|
||||
// Surface unexpected runtime crashes to the user
|
||||
let mut crash_rx = manager.runtime_crash_rx();
|
||||
let app_handle_crash = app_handle_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if crash_rx.wait_for(|status| status.is_some()).await.is_ok() {
|
||||
let status = crash_rx.borrow().clone().unwrap_or_default();
|
||||
// The crash may happen during startup, before any window or
|
||||
// frontend listener exists — wait so the toast isn't lost
|
||||
while app_handle_crash.webview_windows().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let _ = app_handle_crash.emit(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
message: format!("Plugin runtime crashed ({status})"),
|
||||
color: Some(Color::Danger),
|
||||
icon: None,
|
||||
timeout: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app_handle_clone.manage(manager);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"beforeBuildCommand": "npm --prefix ../.. run client:tauri-before-build",
|
||||
"beforeDevCommand": "npm --prefix ../.. run client:tauri-before-dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../../dist/apps/yaak-client"
|
||||
"frontendDist": "../../dist/apps/yaak-client",
|
||||
"features": ["wry"]
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build": {
|
||||
"features": ["updater", "license"]
|
||||
"features": ["updater", "license", "wry"]
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
|
||||
@@ -12,6 +12,10 @@ crate-type = ["staticlib", "cdylib", "lib"]
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.1", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["wry"]
|
||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus"]
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn run() {
|
||||
label: "main_0",
|
||||
title: "Yaak Proxy",
|
||||
inner_size: Some((1000.0, 700.0)),
|
||||
visible: false,
|
||||
hidden: true,
|
||||
hide_titlebar: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yaak-system-appearance"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dark-light = "2.0.0"
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use log::{debug, warn};
|
||||
#[cfg(target_os = "linux")]
|
||||
use tauri::Emitter;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
|
||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Appearance {
|
||||
Dark,
|
||||
Light,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Dark => "dark",
|
||||
Self::Light => "light",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum InitialAppearanceSource {
|
||||
Settings,
|
||||
LinuxSystem,
|
||||
}
|
||||
|
||||
impl InitialAppearanceSource {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Settings => "settings",
|
||||
Self::LinuxSystem => "linux-system",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SystemAppearanceState {
|
||||
// Only read by the Linux polling thread
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
last_appearance: Arc<Mutex<Option<Appearance>>>,
|
||||
}
|
||||
|
||||
pub fn initialization_script(appearance: Appearance, source: InitialAppearanceSource) -> String {
|
||||
let appearance = appearance.as_str();
|
||||
let source = source.as_str();
|
||||
format!(
|
||||
"window.{INITIAL_APPEARANCE_GLOBAL} = {appearance:?};\
|
||||
window.{INITIAL_APPEARANCE_SOURCE_GLOBAL} = {source:?};"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
if let Some(appearance) = gsettings_system_appearance() {
|
||||
return Some(appearance);
|
||||
}
|
||||
|
||||
match dark_light::detect() {
|
||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||
Ok(dark_light::Mode::Unspecified) => None,
|
||||
Err(err) => {
|
||||
debug!("Failed to detect Linux system appearance: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||
let last_appearance = system_appearance();
|
||||
if last_appearance.is_none() {
|
||||
debug!("Linux system appearance detection unavailable");
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||
let thread_state = state.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||
emit_change(&app_handle, &thread_state);
|
||||
}
|
||||
});
|
||||
|
||||
Some(state)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn watch<R: Runtime>(_app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn emit_change<R: Runtime>(app_handle: &AppHandle<R>, state: &SystemAppearanceState) {
|
||||
let appearance = system_appearance();
|
||||
let mut last_appearance =
|
||||
state.last_appearance.lock().expect("system appearance lock poisoned");
|
||||
if appearance == *last_appearance {
|
||||
return;
|
||||
}
|
||||
|
||||
*last_appearance = appearance;
|
||||
if let Some(appearance) = appearance {
|
||||
let appearance = appearance.as_str();
|
||||
debug!("System appearance changed to {appearance}");
|
||||
if let Err(err) = app_handle.emit(SYSTEM_APPEARANCE_CHANGE_EVENT, appearance) {
|
||||
warn!("Failed to emit system appearance change: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gsettings_system_appearance() -> Option<Appearance> {
|
||||
let color_scheme = std::process::Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if color_scheme.contains("prefer-dark") {
|
||||
return Some(Appearance::Dark);
|
||||
}
|
||||
if color_scheme.contains("prefer-light") {
|
||||
return Some(Appearance::Light);
|
||||
}
|
||||
|
||||
let gtk_theme = std::process::Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if gtk_theme.to_lowercase().contains("dark") {
|
||||
return Some(Appearance::Dark);
|
||||
}
|
||||
|
||||
(!gtk_theme.trim().is_empty()).then_some(Appearance::Light)
|
||||
}
|
||||
@@ -8,5 +8,7 @@ publish = false
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
rand = "0.9.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
mod window_state;
|
||||
|
||||
pub mod window;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::window_state;
|
||||
use log::info;
|
||||
use rand::random;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent};
|
||||
@@ -11,18 +12,22 @@ const MIN_WINDOW_HEIGHT: f64 = 300.0;
|
||||
|
||||
pub const MAIN_WINDOW_PREFIX: &str = "main_";
|
||||
const OTHER_WINDOW_PREFIX: &str = "other_";
|
||||
const MAIN_WINDOW_STATE_KEY: &str = "main";
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct CreateWindowConfig<'s> {
|
||||
pub url: &'s str,
|
||||
pub label: &'s str,
|
||||
pub title: &'s str,
|
||||
pub state_key: Option<String>,
|
||||
pub inner_size: Option<(f64, f64)>,
|
||||
pub position: Option<(f64, f64)>,
|
||||
pub restore_position: Option<bool>,
|
||||
pub navigation_tx: Option<mpsc::Sender<String>>,
|
||||
pub close_tx: Option<mpsc::Sender<()>>,
|
||||
pub data_dir_key: Option<String>,
|
||||
pub visible: bool,
|
||||
pub initialization_script: Option<String>,
|
||||
pub hidden: bool,
|
||||
pub hide_titlebar: bool,
|
||||
pub use_native_titlebar: bool,
|
||||
}
|
||||
@@ -32,15 +37,33 @@ pub fn create_window<R: Runtime>(
|
||||
config: CreateWindowConfig,
|
||||
) -> tauri::Result<WebviewWindow<R>> {
|
||||
info!("Create new window label={}", config.label);
|
||||
let state_key = config.state_key.clone().unwrap_or_else(|| config.label.to_string());
|
||||
let restore_position = config.restore_position.unwrap_or(true);
|
||||
let mut inner_size = config.inner_size;
|
||||
let mut position = config.position;
|
||||
let mut maximized = false;
|
||||
window_state::apply_saved_state(
|
||||
handle,
|
||||
&state_key,
|
||||
&mut inner_size,
|
||||
&mut position,
|
||||
&mut maximized,
|
||||
restore_position,
|
||||
);
|
||||
|
||||
let mut win_builder =
|
||||
tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into()))
|
||||
.title(config.title)
|
||||
.resizable(true)
|
||||
.visible(config.visible)
|
||||
.visible(!config.hidden)
|
||||
.fullscreen(false)
|
||||
.maximized(maximized)
|
||||
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
|
||||
|
||||
if let Some(script) = config.initialization_script {
|
||||
win_builder = win_builder.initialization_script(script);
|
||||
}
|
||||
|
||||
if let Some(key) = config.data_dir_key {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
@@ -61,13 +84,13 @@ pub fn create_window<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((w, h)) = config.inner_size {
|
||||
if let Some((w, h)) = inner_size {
|
||||
win_builder = win_builder.inner_size(w, h);
|
||||
} else {
|
||||
win_builder = win_builder.inner_size(600.0, 600.0);
|
||||
}
|
||||
|
||||
if let Some((x, y)) = config.position {
|
||||
if let Some((x, y)) = position {
|
||||
win_builder = win_builder.position(x, y);
|
||||
} else {
|
||||
win_builder = win_builder.center();
|
||||
@@ -103,6 +126,7 @@ pub fn create_window<R: Runtime>(
|
||||
}
|
||||
|
||||
let win = win_builder.build()?;
|
||||
window_state::track_window(&win, &state_key);
|
||||
|
||||
if let Some(tx) = config.close_tx {
|
||||
win.on_window_event(move |event| match event {
|
||||
@@ -119,11 +143,12 @@ pub fn create_window<R: Runtime>(
|
||||
Ok(win)
|
||||
}
|
||||
|
||||
pub fn create_main_window(
|
||||
handle: &AppHandle,
|
||||
pub fn create_main_window<R: Runtime>(
|
||||
handle: &AppHandle<R>,
|
||||
url: &str,
|
||||
initialization_script: Option<String>,
|
||||
use_native_titlebar: bool,
|
||||
) -> tauri::Result<WebviewWindow> {
|
||||
) -> tauri::Result<WebviewWindow<R>> {
|
||||
let mut counter = 0;
|
||||
let label = loop {
|
||||
let label = format!("{MAIN_WINDOW_PREFIX}{counter}");
|
||||
@@ -138,12 +163,16 @@ pub fn create_main_window(
|
||||
url,
|
||||
label: label.as_str(),
|
||||
title: "Yaak",
|
||||
state_key: Some(MAIN_WINDOW_STATE_KEY.to_string()),
|
||||
inner_size: Some((DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)),
|
||||
position: Some((
|
||||
// Offset by random amount so it's easier to differentiate
|
||||
100.0 + random::<f64>() * 20.0,
|
||||
100.0 + random::<f64>() * 20.0,
|
||||
)),
|
||||
restore_position: Some(counter == 0),
|
||||
initialization_script,
|
||||
hidden: true,
|
||||
hide_titlebar: true,
|
||||
use_native_titlebar,
|
||||
..Default::default()
|
||||
@@ -152,15 +181,17 @@ pub fn create_main_window(
|
||||
create_window(handle, config)
|
||||
}
|
||||
|
||||
pub fn create_child_window(
|
||||
parent_window: &WebviewWindow,
|
||||
pub fn create_child_window<R: Runtime>(
|
||||
parent_window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
label: &str,
|
||||
title: &str,
|
||||
inner_size: (f64, f64),
|
||||
initialization_script: Option<String>,
|
||||
use_native_titlebar: bool,
|
||||
) -> tauri::Result<WebviewWindow> {
|
||||
) -> tauri::Result<WebviewWindow<R>> {
|
||||
let app_handle = parent_window.app_handle();
|
||||
let state_key = label.to_string();
|
||||
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
|
||||
let scale_factor = parent_window.scale_factor()?;
|
||||
|
||||
@@ -176,9 +207,12 @@ pub fn create_child_window(
|
||||
let config = CreateWindowConfig {
|
||||
label: label.as_str(),
|
||||
title,
|
||||
state_key: Some(state_key),
|
||||
url,
|
||||
inner_size: Some(inner_size),
|
||||
position: Some(position),
|
||||
initialization_script,
|
||||
hidden: true,
|
||||
hide_titlebar: true,
|
||||
use_native_titlebar,
|
||||
..Default::default()
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
use log::{debug, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, Monitor, Runtime, WebviewWindow, WindowEvent};
|
||||
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const SAVE_DEBOUNCE: Duration = Duration::from_millis(1000);
|
||||
static WINDOW_STATE_FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
|
||||
struct WindowState {
|
||||
width: f64,
|
||||
height: f64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
fn has_size(self) -> bool {
|
||||
self.width > 0.0 && self.height > 0.0
|
||||
}
|
||||
|
||||
fn has_position(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_saved_state<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
state_key: &str,
|
||||
inner_size: &mut Option<(f64, f64)>,
|
||||
position: &mut Option<(f64, f64)>,
|
||||
maximized: &mut bool,
|
||||
restore_position: bool,
|
||||
) {
|
||||
let Some(state) = read_window_state(app_handle, state_key) else {
|
||||
debug!("No saved window state for {state_key}");
|
||||
return;
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Applying saved window state for {state_key}: width={} height={} x={} y={} maximized={} restore_position={restore_position}",
|
||||
state.width, state.height, state.x, state.y, state.maximized
|
||||
);
|
||||
|
||||
if state.has_size() {
|
||||
*inner_size = Some((state.width, state.height));
|
||||
}
|
||||
|
||||
if restore_position && state.has_position() {
|
||||
if is_position_visible(app_handle, state) {
|
||||
*position = Some((state.x, state.y));
|
||||
} else {
|
||||
debug!("Ignoring saved window position for {state_key} because it is off-screen");
|
||||
}
|
||||
}
|
||||
|
||||
*maximized = state.maximized;
|
||||
}
|
||||
|
||||
pub fn track_window<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) {
|
||||
let state_key = state_key.to_string();
|
||||
let save_generation = Arc::new(AtomicU64::new(0));
|
||||
let tracked_window = window.clone();
|
||||
|
||||
window.clone().on_window_event(move |event| match event {
|
||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
|
||||
schedule_save(tracked_window.clone(), state_key.clone(), save_generation.clone());
|
||||
}
|
||||
WindowEvent::CloseRequested { .. } => {
|
||||
save_generation.fetch_add(1, Ordering::Relaxed);
|
||||
if let Err(e) = save_window_state(&tracked_window, &state_key) {
|
||||
warn!("Failed to save window state for {state_key}: {e}");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn schedule_save<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
state_key: String,
|
||||
save_generation: Arc<AtomicU64>,
|
||||
) {
|
||||
let generation = save_generation.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let window_for_dispatch = window.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(SAVE_DEBOUNCE);
|
||||
|
||||
if save_generation.load(Ordering::Relaxed) != generation {
|
||||
return;
|
||||
}
|
||||
|
||||
let state_key_for_save = state_key.clone();
|
||||
let window_for_save = window.clone();
|
||||
if let Err(e) = window_for_dispatch.run_on_main_thread(move || {
|
||||
if let Err(e) = save_window_state(&window_for_save, &state_key_for_save) {
|
||||
warn!("Failed to save window state for {state_key_for_save}: {e}");
|
||||
}
|
||||
}) {
|
||||
debug!("Failed to dispatch debounced window state save for {state_key}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save_window_state<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) -> tauri::Result<()> {
|
||||
let app_handle = window.app_handle();
|
||||
let state_path = window_state_path(&app_handle)?;
|
||||
let _lock = WINDOW_STATE_FILE_LOCK.lock().unwrap();
|
||||
let mut states = read_window_states(&state_path);
|
||||
let mut state = states.get(state_key).copied().unwrap_or_default();
|
||||
|
||||
let maximized = window.is_maximized().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
let scale_factor = window.scale_factor().unwrap_or(1.0);
|
||||
|
||||
if !minimized && (!maximized || !state.has_size()) {
|
||||
let size = window.inner_size()?.to_logical::<f64>(scale_factor);
|
||||
if size.width > 0.0 && size.height > 0.0 {
|
||||
state.width = size.width;
|
||||
state.height = size.height;
|
||||
}
|
||||
}
|
||||
|
||||
if !minimized && (!maximized || !state.has_position()) {
|
||||
let position = window.outer_position()?.to_logical::<f64>(scale_factor);
|
||||
state.x = position.x;
|
||||
state.y = position.y;
|
||||
}
|
||||
|
||||
state.maximized = maximized;
|
||||
states.insert(state_key.to_string(), state);
|
||||
write_window_states(&state_path, &states)?;
|
||||
debug!(
|
||||
"Saved window state for {state_key} to {}: width={} height={} x={} y={} maximized={} minimized={minimized}",
|
||||
state_path.display(),
|
||||
state.width,
|
||||
state.height,
|
||||
state.x,
|
||||
state.y,
|
||||
state.maximized
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_window_state<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
state_key: &str,
|
||||
) -> Option<WindowState> {
|
||||
let state_path = window_state_path(app_handle).ok()?;
|
||||
debug!("Reading window state for {state_key} from {}", state_path.display());
|
||||
read_window_states(&state_path).get(state_key).copied()
|
||||
}
|
||||
|
||||
fn window_state_path<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<PathBuf> {
|
||||
Ok(app_handle.path().app_config_dir()?.join(WINDOW_STATE_FILE))
|
||||
}
|
||||
|
||||
fn read_window_states(state_path: &PathBuf) -> HashMap<String, WindowState> {
|
||||
let Ok(bytes) = fs::read(state_path) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
match serde_json::from_slice(&bytes) {
|
||||
Ok(states) => states,
|
||||
Err(e) => {
|
||||
warn!("Failed to read window state {}: {e}", state_path.display());
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_window_states(
|
||||
state_path: &PathBuf,
|
||||
states: &HashMap<String, WindowState>,
|
||||
) -> tauri::Result<()> {
|
||||
if let Some(parent) = state_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(state_path, serde_json::to_vec_pretty(states)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_position_visible<R: Runtime>(app_handle: &AppHandle<R>, state: WindowState) -> bool {
|
||||
let Ok(monitors) = app_handle.available_monitors() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
monitors.into_iter().any(|monitor| monitor_intersects_window(&monitor, state))
|
||||
}
|
||||
|
||||
fn monitor_intersects_window(monitor: &Monitor, state: WindowState) -> bool {
|
||||
let scale_factor = monitor.scale_factor();
|
||||
let position = monitor.position().to_logical::<f64>(scale_factor);
|
||||
let size = monitor.size().to_logical::<f64>(scale_factor);
|
||||
|
||||
let left = position.x;
|
||||
let right = position.x + size.width;
|
||||
let top = position.y;
|
||||
let bottom = position.y + size.height;
|
||||
|
||||
[
|
||||
(state.x, state.y),
|
||||
(state.x + state.width, state.y),
|
||||
(state.x, state.y + state.height),
|
||||
(state.x + state.width, state.y + state.height),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|(x, y)| x >= left && x < right && y >= top && y < bottom)
|
||||
}
|
||||
@@ -18,3 +18,7 @@ url = "2"
|
||||
yaak-common = { workspace = true }
|
||||
yaak-models = { 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 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>, };
|
||||
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::repository::open_repo;
|
||||
use crate::status::repo_relative_dir;
|
||||
use log::info;
|
||||
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<()> {
|
||||
let out =
|
||||
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?;
|
||||
// Run git from the repo root: command-line pathspecs resolve relative to
|
||||
// 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 stderr = String::from_utf8_lossy(&out.stderr);
|
||||
@@ -19,3 +47,60 @@ pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user