mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-02 00:27:16 +02:00
Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc | ||
|
|
66c13d7e13 | ||
|
|
388dd8815f | ||
|
|
cb295b7102 | ||
|
|
4e835cf7e8 | ||
|
|
2350689d7a | ||
|
|
4eebd606ef | ||
|
|
c4f96f3f11 | ||
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da | ||
|
|
6a02cbe525 | ||
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 | ||
|
|
23e7229e63 | ||
|
|
1f91cddab9 | ||
|
|
be004425fa | ||
|
|
4f03c5c390 | ||
|
|
cb43f0b9b9 | ||
|
|
68671377fd | ||
|
|
2383f06e71 | ||
|
|
0068be9ffc | ||
|
|
def7d752db | ||
|
|
85aed740a8 | ||
|
|
d3055c64b3 | ||
|
|
912aaeb7a7 | ||
|
|
d0af512a0c | ||
|
|
e032c4c8d6 | ||
|
|
c3fc3fb68a | ||
|
|
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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
target
|
||||||
|
**/target
|
||||||
|
.claude
|
||||||
|
vendored
|
||||||
|
**/vendored
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
## Submission
|
## Submission
|
||||||
|
|
||||||
|
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
|
||||||
|
|
||||||
- [ ] This PR is a bug fix.
|
- [ ] This PR is a bug fix.
|
||||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
- [ ] I tested this change locally.
|
- [ ] I tested this change locally.
|
||||||
- [ ] I added or updated tests when reasonable.
|
- [ ] I added or updated tests, or tests are not reasonable for this change.
|
||||||
- [ ] I added screenshots or recordings for UI changes when reasonable.
|
- [ ] I added screenshots or recordings, or this change does not affect the UI.
|
||||||
|
|
||||||
Explicit permission feedback item (required if not a bug fix):
|
Explicit permission feedback item (required if not a bug fix):
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ const REVIEWER_LOGIN = "gschier";
|
|||||||
const LARGE_DIFF_CHANGED_FILES = 20;
|
const LARGE_DIFF_CHANGED_FILES = 20;
|
||||||
const LARGE_DIFF_CHANGED_LINES = 800;
|
const LARGE_DIFF_CHANGED_LINES = 800;
|
||||||
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
||||||
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
|
const MIN_AUTOMATIC_PR_NUMBER = 494;
|
||||||
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
|
|
||||||
|
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
inScope: {
|
inScope: {
|
||||||
@@ -54,16 +53,25 @@ const MANAGED_LABEL_NAMES = [
|
|||||||
...new Set(Object.values(LABELS).map((label) => label.name)),
|
...new Set(Object.values(LABELS).map((label) => label.name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Each checkbox lists its current label first, followed by legacy labels still
|
||||||
|
// accepted from PRs opened against older versions of the template.
|
||||||
const CHECKBOXES = {
|
const CHECKBOXES = {
|
||||||
bugFix: "This PR is a bug fix.",
|
bugFix: ["This PR is a bug fix."],
|
||||||
explicitPermission:
|
explicitPermission: [
|
||||||
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
||||||
readContributing:
|
],
|
||||||
|
readContributing: [
|
||||||
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
||||||
testedLocally: "I tested this change locally.",
|
],
|
||||||
testsUpdated: "I added or updated tests when reasonable.",
|
testedLocally: ["I tested this change locally."],
|
||||||
screenshotsAdded:
|
testsUpdated: [
|
||||||
|
"I added or updated tests, or tests are not reasonable for this change.",
|
||||||
|
"I added or updated tests when reasonable.",
|
||||||
|
],
|
||||||
|
screenshotsAdded: [
|
||||||
|
"I added screenshots or recordings, or this change does not affect the UI.",
|
||||||
"I added screenshots or recordings for UI changes when reasonable.",
|
"I added screenshots or recordings for UI changes when reasonable.",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeRegExp(value) {
|
function escapeRegExp(value) {
|
||||||
@@ -103,8 +111,8 @@ function normalizeCheckboxLabel(label) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkboxState(body, label) {
|
function checkboxState(body, labels) {
|
||||||
const expectedLabel = normalizeCheckboxLabel(label);
|
const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
|
||||||
|
|
||||||
for (const line of body.split("\n")) {
|
for (const line of body.split("\n")) {
|
||||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
||||||
@@ -113,7 +121,7 @@ function checkboxState(body, label) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
|
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
|
||||||
return match[1].toLowerCase() === "x";
|
return match[1].toLowerCase() === "x";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
if (!templateUsed) {
|
||||||
blockers.push({
|
blockers.push({
|
||||||
label: LABELS.missingTemplate.name,
|
label: LABELS.missingTemplate.name,
|
||||||
@@ -244,7 +264,8 @@ function analyzePullRequest(pr) {
|
|||||||
if (states.testsUpdated !== true) {
|
if (states.testsUpdated !== true) {
|
||||||
blockers.push({
|
blockers.push({
|
||||||
label: LABELS.policyUnmet.name,
|
label: LABELS.policyUnmet.name,
|
||||||
message: "Confirm that tests were added or updated when reasonable.",
|
message:
|
||||||
|
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +273,7 @@ function analyzePullRequest(pr) {
|
|||||||
blockers.push({
|
blockers.push({
|
||||||
label: LABELS.policyUnmet.name,
|
label: LABELS.policyUnmet.name,
|
||||||
message:
|
message:
|
||||||
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
|
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }) {
|
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
||||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -603,7 +620,7 @@ async function checkPullRequest({
|
|||||||
repo,
|
repo,
|
||||||
pullNumber,
|
pullNumber,
|
||||||
dryRun,
|
dryRun,
|
||||||
skipCreatedBefore,
|
minimumAutomaticPullNumber,
|
||||||
}) {
|
}) {
|
||||||
const response = await github.rest.pulls.get({
|
const response = await github.rest.pulls.get({
|
||||||
owner,
|
owner,
|
||||||
@@ -613,12 +630,9 @@ async function checkPullRequest({
|
|||||||
const pr = response.data;
|
const pr = response.data;
|
||||||
const issueNumber = pr.number;
|
const issueNumber = pr.number;
|
||||||
|
|
||||||
if (
|
if (pr.user.type === "Bot") {
|
||||||
skipCreatedBefore != null &&
|
|
||||||
wasCreatedBefore(pr.created_at, skipCreatedBefore)
|
|
||||||
) {
|
|
||||||
core.notice(
|
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 {
|
return {
|
||||||
blocked: false,
|
blocked: false,
|
||||||
@@ -626,7 +640,26 @@ async function checkPullRequest({
|
|||||||
summary: summarizeResult({
|
summary: summarizeResult({
|
||||||
pr,
|
pr,
|
||||||
skipped: true,
|
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,
|
skipped: true,
|
||||||
};
|
};
|
||||||
@@ -756,8 +789,8 @@ async function run({ github, context, core }) {
|
|||||||
context.eventName === "workflow_dispatch" &&
|
context.eventName === "workflow_dispatch" &&
|
||||||
dryRunInput !== false &&
|
dryRunInput !== false &&
|
||||||
dryRunInput !== "false";
|
dryRunInput !== "false";
|
||||||
const skipCreatedBefore =
|
const minimumAutomaticPullNumber =
|
||||||
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
|
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
|
||||||
let pullNumbers;
|
let pullNumbers;
|
||||||
|
|
||||||
if (payloadPr != null) {
|
if (payloadPr != null) {
|
||||||
@@ -795,7 +828,7 @@ async function run({ github, context, core }) {
|
|||||||
repo,
|
repo,
|
||||||
pullNumber: pr.number,
|
pullNumber: pr.number,
|
||||||
dryRun,
|
dryRun,
|
||||||
skipCreatedBefore,
|
minimumAutomaticPullNumber,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ jobs:
|
|||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all
|
run: cargo test --all --features yaak-app-client/wry
|
||||||
|
- name: OpenAPI import round-trip
|
||||||
|
run: |
|
||||||
|
cargo build -p yaak-cli
|
||||||
|
node plugins/importer-openapi/tests/roundtrip.mjs
|
||||||
|
|||||||
@@ -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:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
name: Check contribution policy
|
name: Check contribution policy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout policy script
|
- name: Checkout policy script
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.base.sha || github.ref }}
|
ref: main
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check contribution policy
|
- name: Check contribution policy
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-flathub:
|
update-flathub:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
name: Update Flathub manifest
|
name: Update Flathub manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -34,7 +35,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "24"
|
||||||
|
|
||||||
- name: Install source generators
|
- name: Install source generators
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-npm:
|
publish-npm:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
name: Publish @yaakapp/api
|
name: Publish @yaakapp/api
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -14,38 +15,62 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
||||||
args: "--target aarch64-apple-darwin"
|
args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "macos"
|
os: "macos"
|
||||||
|
runtime: "wry"
|
||||||
targets: "aarch64-apple-darwin"
|
targets: "aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel-based Macs.
|
- platform: "macos-latest" # for Intel-based Macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "macos"
|
os: "macos"
|
||||||
|
runtime: "wry"
|
||||||
targets: "x86_64-apple-darwin"
|
targets: "x86_64-apple-darwin"
|
||||||
- platform: "ubuntu-22.04"
|
- platform: "ubuntu-22.04"
|
||||||
args: ""
|
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "ubuntu"
|
os: "ubuntu"
|
||||||
|
runtime: "wry"
|
||||||
targets: ""
|
targets: ""
|
||||||
- platform: "ubuntu-22.04-arm"
|
- platform: "ubuntu-22.04-arm"
|
||||||
args: ""
|
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "ubuntu"
|
os: "ubuntu"
|
||||||
|
runtime: "wry"
|
||||||
|
targets: ""
|
||||||
|
- platform: "ubuntu-22.04"
|
||||||
|
args: >-
|
||||||
|
--bundles deb
|
||||||
|
--config ./tauri.release.conf.json
|
||||||
|
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||||
|
yaak_arch: "x64"
|
||||||
|
os: "ubuntu"
|
||||||
|
runtime: "cef"
|
||||||
|
targets: ""
|
||||||
|
- platform: "ubuntu-22.04-arm"
|
||||||
|
args: >-
|
||||||
|
--bundles deb
|
||||||
|
--config ./tauri.release.conf.json
|
||||||
|
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||||
|
yaak_arch: "arm64"
|
||||||
|
os: "ubuntu"
|
||||||
|
runtime: "cef"
|
||||||
targets: ""
|
targets: ""
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: ""
|
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "windows"
|
os: "windows"
|
||||||
|
runtime: "wry"
|
||||||
targets: ""
|
targets: ""
|
||||||
# Windows ARM64
|
# Windows ARM64
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: "--target aarch64-pc-windows-msvc"
|
args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "windows"
|
os: "windows"
|
||||||
|
runtime: "wry"
|
||||||
targets: "aarch64-pc-windows-msvc"
|
targets: "aarch64-pc-windows-msvc"
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 40
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout yaakapp/app
|
- name: Checkout yaakapp/app
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -66,11 +91,30 @@ jobs:
|
|||||||
shared-key: ci
|
shared-key: ci
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
|
|
||||||
|
- name: Cache CEF (Linux only)
|
||||||
|
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/tauri-cef
|
||||||
|
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
|
||||||
- name: install dependencies (Linux only)
|
- name: install dependencies (Linux only)
|
||||||
if: matrix.os == 'ubuntu'
|
if: matrix.os == 'ubuntu'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y 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
|
||||||
|
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||||
|
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||||
|
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||||
|
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||||
|
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||||
|
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||||
|
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||||
|
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||||
|
chmod +x /tmp/llvm.sh
|
||||||
|
sudo /tmp/llvm.sh 18
|
||||||
|
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||||
|
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Install Protoc for plugin-runtime
|
- name: Install Protoc for plugin-runtime
|
||||||
uses: arduino/setup-protoc@v3
|
uses: arduino/setup-protoc@v3
|
||||||
@@ -98,7 +142,7 @@ jobs:
|
|||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --exclude yaak-cli
|
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
run: npm run replace-version
|
run: npm run replace-version
|
||||||
@@ -150,13 +194,30 @@ jobs:
|
|||||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||||
with:
|
with:
|
||||||
|
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||||
tagName: "v__VERSION__"
|
tagName: "v__VERSION__"
|
||||||
releaseName: "Release __VERSION__"
|
releaseName: "Release __VERSION__"
|
||||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
releaseBody: "<!-- generated-by-yaak-releases -->"
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
projectPath: ./crates-tauri/yaak-app-client
|
projectPath: ./crates-tauri/yaak-app-client
|
||||||
args: "${{ matrix.args }} --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)
|
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||||
- name: Build and upload machine-wide installer (Windows only)
|
- name: Build and upload machine-wide installer (Windows only)
|
||||||
@@ -173,7 +234,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||||
Push-Location crates-tauri/yaak-app-client
|
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
|
Pop-Location
|
||||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||||
$setupSig = "$($setup.FullName).sig"
|
$setupSig = "$($setup.FullName).sig"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Release CLI to NPM
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [yaak-cli-*]
|
tags: [v*]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -15,6 +15,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-vendored-assets:
|
prepare-vendored-assets:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
name: Prepare vendored plugin assets
|
name: Prepare vendored plugin assets
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ jobs:
|
|||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
VERSION="$WORKFLOW_VERSION"
|
VERSION="$WORKFLOW_VERSION"
|
||||||
else
|
else
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
VERSION="${GITHUB_REF_NAME}"
|
||||||
fi
|
fi
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
echo "Building yaak version: $VERSION"
|
echo "Building yaak version: $VERSION"
|
||||||
@@ -175,7 +176,7 @@ jobs:
|
|||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
VERSION="$WORKFLOW_VERSION"
|
VERSION="$WORKFLOW_VERSION"
|
||||||
else
|
else
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
VERSION="${GITHUB_REF_NAME}"
|
||||||
fi
|
fi
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
if [[ "$VERSION" == *-* ]]; then
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Release web image
|
||||||
|
|
||||||
|
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||||
|
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||||
|
# joined into one multi-arch tag at the end.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [v*]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-22.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-22.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.web
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/digests"
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.arch }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish manifest
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||||
|
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||||
|
- name: Tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
flavor: latest=false
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Create and push the manifest
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect
|
||||||
|
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -7,6 +7,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout 🛎️
|
- name: Checkout 🛎️
|
||||||
|
|||||||
@@ -58,3 +58,4 @@ flatpak/node-sources.json
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.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
|
- Do not commit, push, or tag without explicit approval
|
||||||
|
|||||||
Generated
+1494
-201
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -2,9 +2,11 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
|
"crates/yaak-commands",
|
||||||
# Common/foundation crates
|
# Common/foundation crates
|
||||||
"crates/common/yaak-database",
|
"crates/common/yaak-database",
|
||||||
"crates/common/yaak-rpc",
|
"crates/common/yaak-rpc",
|
||||||
|
"crates/common/yaak-rpc-schema",
|
||||||
# Shared crates (no Tauri dependency)
|
# Shared crates (no Tauri dependency)
|
||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
@@ -12,6 +14,7 @@ members = [
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-http",
|
"crates/yaak-http",
|
||||||
|
"crates/yaak-lifecycle",
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
@@ -19,10 +22,13 @@ members = [
|
|||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
"crates/yaak-tls",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-ws",
|
"crates/yaak-ws",
|
||||||
|
"crates/yaak-wasm",
|
||||||
"crates/yaak-api",
|
"crates/yaak-api",
|
||||||
"crates/yaak-proxy",
|
"crates/yaak-proxy",
|
||||||
# Proxy-specific crates
|
# Proxy-specific crates
|
||||||
"crates-proxy/yaak-proxy-lib",
|
"crates-proxy/yaak-proxy-lib",
|
||||||
|
# Server crates (the browser tier's hosted send executor)
|
||||||
|
"crates-server/yaak-web",
|
||||||
# CLI crates
|
# CLI crates
|
||||||
"crates-cli/yaak-cli",
|
"crates-cli/yaak-cli",
|
||||||
# Tauri-specific crates
|
# Tauri-specific crates
|
||||||
@@ -31,6 +37,7 @@ members = [
|
|||||||
"crates-tauri/yaak-fonts",
|
"crates-tauri/yaak-fonts",
|
||||||
"crates-tauri/yaak-license",
|
"crates-tauri/yaak-license",
|
||||||
"crates-tauri/yaak-mac-window",
|
"crates-tauri/yaak-mac-window",
|
||||||
|
"crates-tauri/yaak-system-appearance",
|
||||||
"crates-tauri/yaak-tauri-utils",
|
"crates-tauri/yaak-tauri-utils",
|
||||||
"crates-tauri/yaak-window",
|
"crates-tauri/yaak-window",
|
||||||
]
|
]
|
||||||
@@ -47,9 +54,13 @@ schemars = { version = "0.8.22", features = ["chrono"] }
|
|||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tauri = "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 = "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"
|
tauri-plugin-shell = "2.3.5"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
@@ -58,15 +69,18 @@ ts-rs = "11.1.0"
|
|||||||
# Internal crates - common/foundation
|
# Internal crates - common/foundation
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
yaak-database = { path = "crates/common/yaak-database" }
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||||
|
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
yaak = { path = "crates/yaak" }
|
||||||
|
yaak-commands = { path = "crates/yaak-commands" }
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
yaak-http = { path = "crates/yaak-http" }
|
||||||
|
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
yaak-models = { path = "crates/yaak-models" }
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
yaak-sse = { path = "crates/yaak-sse" }
|
||||||
@@ -84,8 +98,13 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||||
|
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = false
|
strip = false
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||||
|
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Run the App in a Browser
|
||||||
|
|
||||||
|
The client can also run as a plain web page, with no Tauri and no local process
|
||||||
|
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||||
|
commands from an IndexedDB database the page owns instead of from the Rust
|
||||||
|
engine. Data persists across reloads and is shared between tabs on the same
|
||||||
|
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||||
|
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||||
|
lists which commands the browser host implements and which it declines.
|
||||||
|
|
||||||
|
Desktop builds are unaffected: without the flag the platform package installs
|
||||||
|
the Tauri host exactly as before.
|
||||||
|
|
||||||
## SQLite Migrations
|
## SQLite Migrations
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
New migrations can be created from the `src-tauri/` directory:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||||
|
# image serving both from one origin.
|
||||||
|
#
|
||||||
|
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||||
|
#
|
||||||
|
# See crates-server/yaak-web/README.md for the knobs.
|
||||||
|
|
||||||
|
FROM node:22-slim AS web
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||||
|
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||||
|
RUN git init -q && git add -A \
|
||||||
|
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||||
|
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||||
|
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||||
|
ARG VITE_YAAK_WEB_URL=""
|
||||||
|
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||||
|
ENV YAAK_TARGET=web
|
||||||
|
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||||
|
# backend, which this image has no reason to carry.
|
||||||
|
ENV SKIP_WASM_BUILD=1
|
||||||
|
RUN npm ci
|
||||||
|
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS server
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p yaak-web
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||||
|
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||||
|
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nobody
|
||||||
|
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||||
|
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||||
|
CMD ["yaak-web", "--serve", "/srv"]
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { patchModelById } from "@yaakapp-internal/models";
|
||||||
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
|
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||||
|
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||||
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
|
export function looksLikeCurl(text: string) {
|
||||||
|
return text.trim().startsWith("curl ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importCurl = createFastMutation<
|
||||||
|
void,
|
||||||
|
string,
|
||||||
|
{ overwriteRequestId?: string; command: string }
|
||||||
|
>({
|
||||||
|
mutationKey: ["import_curl"],
|
||||||
|
mutationFn: async ({ overwriteRequestId, command }) => {
|
||||||
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
|
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
|
||||||
|
command,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let verb: string;
|
||||||
|
if (overwriteRequestId == null) {
|
||||||
|
verb = "Created";
|
||||||
|
await createRequestAndNavigate(importedRequest);
|
||||||
|
} else {
|
||||||
|
verb = "Updated";
|
||||||
|
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
||||||
|
...importedRequest,
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
folderId: r.folderId,
|
||||||
|
name: r.name,
|
||||||
|
sortPriority: r.sortPriority,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
color: "success",
|
||||||
|
message: `${verb} request from Curl`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,26 +1,44 @@
|
|||||||
import type { SettingsTab } from "../components/Settings/Settings";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
|
||||||
mutationKey: ["open_settings"],
|
mutationKey: ["open_settings"],
|
||||||
mutationFn: async (tab) => {
|
mutationFn: async (tab) => {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
if (workspaceId == null) return;
|
||||||
|
|
||||||
|
// Settings is its own window where the host has windows to give. Where it
|
||||||
|
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||||
|
// doesn't take you away from the request you were working on.
|
||||||
|
if (!platform.capabilities.multiWindow) {
|
||||||
|
// Imported here so Settings stays out of the startup bundle, the way the
|
||||||
|
// route that renders it on desktop already keeps it
|
||||||
|
const { default: Settings } = await import("../components/Settings/Settings");
|
||||||
|
showDialog({
|
||||||
|
id: "settings",
|
||||||
|
size: "md",
|
||||||
|
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||||
|
noPadding: true,
|
||||||
|
noScroll: true,
|
||||||
|
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||||
|
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId/settings",
|
to: "/workspaces/$workspaceId/settings",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
await invokeCmd("cmd_new_child_window", {
|
await rpc("cmd_new_child_window", {
|
||||||
url: location.href,
|
url: location.href,
|
||||||
label: "settings",
|
label: "settings",
|
||||||
title: "Yaak Settings",
|
title: "Yaak Settings",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export const switchWorkspace = createFastMutation<
|
export const switchWorkspace = createFastMutation<
|
||||||
void,
|
void,
|
||||||
@@ -24,13 +25,15 @@ export const switchWorkspace = createFastMutation<
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (inNewWindow) {
|
// A host without windows opens the workspace here instead. Refusing would
|
||||||
|
// strand the user on the workspace they were trying to leave.
|
||||||
|
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId",
|
to: "/workspaces/$workspaceId",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search,
|
search,
|
||||||
});
|
});
|
||||||
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
|
await rpc<void>("cmd_new_main_window", { url: location.href });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { gitClone } from "@yaakapp-internal/git";
|
import { gitClone } from "@yaakapp-internal/git";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -11,6 +10,7 @@ import { Checkbox } from "./core/Checkbox";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { promptCredentials } from "./git/credentials";
|
import { promptCredentials } from "./git/credentials";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
@@ -38,7 +38,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
const handleSelectDirectory = async () => {
|
||||||
const dir = await open({
|
const dir = await platform.dialog.open({
|
||||||
title: "Select Directory",
|
title: "Select Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { checkLicense } from "@yaakapp-internal/license";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
import { pricingUrl } from "../lib/pricingUrl";
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
const COMMERCIAL_USE_BANNER_MESSAGE =
|
||||||
@@ -95,7 +94,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
|
const license = await checkLicense();
|
||||||
return license.status === "personal_use";
|
return license.status === "personal_use";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("Failed to check license before commercial-use prompt", err);
|
console.log("Failed to check license before commercial-use prompt", err);
|
||||||
@@ -104,7 +103,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCommercialUsePricing(source: string): Promise<void> {
|
async function openCommercialUsePricing(source: string): Promise<void> {
|
||||||
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSnoozed(value: string | null, ms: number): boolean {
|
function isSnoozed(value: string | null, ms: number): boolean {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -39,7 +39,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
|
|
||||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
// Do getWorkspaceMeta instead of naively creating one because it might have
|
||||||
// been created already when the store refreshes the workspace meta after
|
// been created already when the store refreshes the workspace meta after
|
||||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
|
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
await updateModel({
|
await updateModel({
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
|||||||
)}
|
)}
|
||||||
// If no environments, the button simply opens the dialog.
|
// If no environments, the button simply opens the dialog.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
onClick={
|
||||||
|
subEnvironments.length === 0
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
editEnvironment(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { Workspace } from "@yaakapp-internal/models";
|
import type { Workspace } from "@yaakapp-internal/models";
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||||
@@ -7,12 +6,13 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
@@ -65,7 +65,7 @@ function ExportDataDialogContent({
|
|||||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||||
const exportPath = await save({
|
const exportPath = await platform.dialog.save({
|
||||||
title: "Export Data",
|
title: "Export Data",
|
||||||
defaultPath: `yaak.${slug}.json`,
|
defaultPath: `yaak.${slug}.json`,
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ function ExportDataDialogContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await invokeCmd("cmd_export_data", {
|
await rpc("cmd_export_data", {
|
||||||
workspaceIds: ids,
|
workspaceIds: ids,
|
||||||
exportPath,
|
exportPath,
|
||||||
includePrivateEnvironments: includePrivateEnvironments,
|
includePrivateEnvironments: includePrivateEnvironments,
|
||||||
|
|||||||
@@ -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 { rpc } from "../lib/rpc";
|
||||||
|
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
|
||||||
|
rpc("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,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||||
@@ -8,6 +7,7 @@ import { pluralizeCount } from "../lib/pluralize";
|
|||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
@@ -45,7 +45,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="border"
|
variant="border"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await open({
|
const selected = await platform.dialog.open({
|
||||||
title: "Select Proto Files",
|
title: "Select Proto Files",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||||
@@ -63,7 +63,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
variant="border"
|
variant="border"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await open({
|
const selected = await platform.dialog.open({
|
||||||
title: "Select Proto Directory",
|
title: "Select Proto Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
|
|||||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||||
|
|
||||||
const handleChangeUrl = useCallback(
|
const handleChangeUrl = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeMessage = useCallback(
|
const handleChangeMessage = useCallback(
|
||||||
|
// Not debounced: handleSend reads message from the store, so a pending
|
||||||
|
// debounced patch would send stale text
|
||||||
(message: string) => patchModel(activeRequest, { message }),
|
(message: string) => patchModel(activeRequest, { message }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleMetadataChange = useCallback(
|
const handleMetadataChange = useCallback(
|
||||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
const handleDescriptionChange = useCallback(
|
||||||
(description: string) => patchModel(activeRequest, { description }),
|
(description: string) => patchModelDebounced(activeRequest, { description }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,7 +306,7 @@ export function GrpcRequestPane({
|
|||||||
className="font-sans text-xl! px-0!"
|
className="font-sans text-xl! px-0!"
|
||||||
containerClassName="border-0"
|
containerClassName="border-0"
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
placeholder={resolvedModelName(activeRequest)}
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
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 { charsets } from "../lib/data/charsets";
|
||||||
import { connections } from "../lib/data/connections";
|
import { connections } from "../lib/data/connections";
|
||||||
import { encodings } from "../lib/data/encodings";
|
import { encodings } from "../lib/data/encodings";
|
||||||
|
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
||||||
import { headerNames } from "../lib/data/headerNames";
|
import { headerNames } from "../lib/data/headerNames";
|
||||||
import { mimeTypes } from "../lib/data/mimetypes";
|
import { mimeTypes } from "../lib/data/mimetypes";
|
||||||
|
import { userAgents } from "../lib/data/userAgents";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
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 { InputProps } from "./core/Input";
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||||
import { PairEditorRow } from "./core/PairEditor";
|
import { PairEditorRow } from "./core/PairEditor";
|
||||||
@@ -105,12 +108,21 @@ export function HeadersEditor({
|
|||||||
|
|
||||||
const MIN_MATCH = 3;
|
const MIN_MATCH = 3;
|
||||||
|
|
||||||
const headerOptionsMap: Record<string, string[]> = {
|
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||||
"content-type": mimeTypes,
|
"content-type": mimeTypes,
|
||||||
accept: ["*/*", ...mimeTypes],
|
accept: ["*/*", ...mimeTypes],
|
||||||
"accept-encoding": encodings,
|
"accept-encoding": encodings,
|
||||||
|
"content-encoding": encodings,
|
||||||
connection: connections,
|
connection: connections,
|
||||||
"accept-charset": charsets,
|
"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"] => {
|
const valueType = (pair: Pair): InputProps["type"] => {
|
||||||
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
|
|||||||
|
|
||||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||||
const name = headerName.toLowerCase().trim();
|
const name = headerName.toLowerCase().trim();
|
||||||
const options: GenericCompletionOption[] =
|
const options: GenericCompletion[] =
|
||||||
headerOptionsMap[name]?.map((o) => ({
|
headerOptionsMap[name]?.map((o) =>
|
||||||
|
typeof o === "string"
|
||||||
|
? {
|
||||||
label: o,
|
label: o,
|
||||||
type: "constant",
|
type: "constant",
|
||||||
boost: 1, // Put above other completions
|
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 };
|
return { minMatch: MIN_MATCH, options };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
|
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
BODY_TYPE_FORM_MULTIPART,
|
||||||
@@ -38,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
|
|||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } 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 TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
// actually changes. The active request's own URL is included, but exact matches
|
||||||
const requests = get(allRequestsAtom);
|
// are filtered out at completion time by genericCompletion.
|
||||||
return requests
|
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||||
.filter((r) => r.id !== activeRequestId)
|
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -131,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("http_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
let numParams = 0;
|
let numParams = 0;
|
||||||
if (
|
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;
|
let newContentType: string | null | undefined;
|
||||||
if (bodyType === BODY_TYPE_NONE) {
|
if (bodyType === BODY_TYPE_NONE) {
|
||||||
newContentType = null;
|
newContentType = null;
|
||||||
@@ -262,38 +278,28 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||||
const { mutate: importCurl } = useImportCurl();
|
|
||||||
|
|
||||||
const handleBodyChange = useCallback(
|
const handleBodyChange = useCallback(
|
||||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
const handleBodyTextChange = useCallback(
|
||||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => ({
|
() => getUrlCompletionConfig(autocompleteUrls),
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
async (e: ClipboardEvent, text: string) => {
|
async (e: ClipboardEvent, text: string) => {
|
||||||
if (text.startsWith("curl ")) {
|
if (looksLikeCurl(text)) {
|
||||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
|
||||||
} else {
|
} else {
|
||||||
const patch = prepareImportQuerystring(text);
|
const patch = prepareImportQuerystring(text);
|
||||||
if (patch != null) {
|
if (patch != null) {
|
||||||
@@ -315,7 +321,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||||
);
|
);
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
() => sendRequest(activeRequest.id ?? null),
|
() => sendRequest(activeRequest.id ?? null),
|
||||||
@@ -323,7 +329,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -369,7 +375,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -377,7 +383,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_SETTINGS}>
|
<TabContent value={TAB_SETTINGS}>
|
||||||
@@ -429,7 +435,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
requestId={activeRequest.id}
|
requestId={activeRequest.id}
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
body={activeRequest.body}
|
body={activeRequest.body}
|
||||||
onChange={(body) => patchModel(activeRequest, { body })}
|
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||||
onChangeContentType={handleContentTypeChange}
|
onChangeContentType={handleContentTypeChange}
|
||||||
/>
|
/>
|
||||||
) : typeof activeRequest.bodyType === "string" ? (
|
) : typeof activeRequest.bodyType === "string" ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
|||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||||
|
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||||
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
|
|||||||
Component,
|
Component,
|
||||||
}: {
|
}: {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
Component: ComponentType<{ bodyPath: string }>;
|
Component: ComponentType<{ bodyUrl: string }>;
|
||||||
}) {
|
}) {
|
||||||
if (response.bodyPath === null) {
|
// Wait until the response has been fully-downloaded before asking for it
|
||||||
return <div>Empty response body</div>;
|
const complete = response.state === "closed";
|
||||||
}
|
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||||
|
|
||||||
// Wait until the response has been fully-downloaded
|
if (!complete || bodyUrl.isPending) {
|
||||||
if (response.state !== "closed") {
|
|
||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<LoadingIcon />
|
<LoadingIcon />
|
||||||
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Component bodyPath={response.bodyPath} />;
|
if (bodyUrl.error) {
|
||||||
|
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyUrl.data == null) {
|
||||||
|
return <div>Empty response body</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component bodyUrl={bodyUrl.data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { platform, useCapability } from "@yaakapp-internal/platform";
|
||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import * as m from "motion/react-m";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||||
|
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers to create a request from a Curl command on the clipboard. A host that can read
|
||||||
|
* the clipboard on its own offers it whenever the window is focused; one that would have
|
||||||
|
* to prompt for permission first waits for the user to paste instead.
|
||||||
|
*/
|
||||||
|
export function ImportCurl() {
|
||||||
|
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportCurlButton() {
|
||||||
|
const focused = useWindowFocus();
|
||||||
|
const [clipboardText, setClipboardText] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||||
|
useEffect(() => {
|
||||||
|
void platform.clipboard.readText().then(setClipboardText);
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
if (!looksLikeCurl(clipboardText)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color="success"
|
||||||
|
className="rounded-full"
|
||||||
|
rightSlot={<Icon icon="import" size="sm" />}
|
||||||
|
isLoading={isLoading}
|
||||||
|
title="Import Curl command from clipboard"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await importCurl.mutateAsync({ command: clipboardText });
|
||||||
|
setClipboardText(""); // Hide the button until the clipboard changes
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to import curl", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import Curl
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportCurlOnPaste() {
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const command = e.clipboardData?.getData("text/plain") ?? "";
|
||||||
|
if (!looksLikeCurl(command)) return;
|
||||||
|
|
||||||
|
// Editable targets keep the text as text. The URL editor imports it itself.
|
||||||
|
if (isEditable(e.target)) return;
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
id: "curl-paste",
|
||||||
|
color: "success",
|
||||||
|
message: (
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">Curl command detected</h2>
|
||||||
|
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
action: ({ hide }) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="success"
|
||||||
|
className="mr-auto min-w-20"
|
||||||
|
rightSlot={<Icon icon="import" size="sm" />}
|
||||||
|
onClick={() => {
|
||||||
|
hide();
|
||||||
|
importCurl.mutate({ command });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Request
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("paste", handlePaste);
|
||||||
|
return () => document.removeEventListener("paste", handlePaste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditable(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
if (target.closest(".cm-editor") != null) return true;
|
||||||
|
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export function ImportCurlButton() {
|
|
||||||
const focused = useWindowFocus();
|
|
||||||
const [clipboardText, setClipboardText] = useState("");
|
|
||||||
|
|
||||||
const importCurl = useImportCurl();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
|
||||||
useEffect(() => {
|
|
||||||
void readText().then(setClipboardText);
|
|
||||||
}, [focused]);
|
|
||||||
|
|
||||||
if (!clipboardText?.trim().startsWith("curl ")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color="success"
|
|
||||||
className="rounded-full"
|
|
||||||
rightSlot={<Icon icon="import" size="sm" />}
|
|
||||||
isLoading={isLoading}
|
|
||||||
title="Import Curl command from clipboard"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await importCurl.mutateAsync({ command: clipboardText });
|
|
||||||
await clear(); // Clear the clipboard so the button goes away
|
|
||||||
setClipboardText("");
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Failed to import curl", e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Import Curl
|
|
||||||
</Button>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +1,138 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useState } from "react";
|
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
importData: (filePath: string) => Promise<void>;
|
importFile: (filePath: string) => Promise<void>;
|
||||||
|
importUrl: (url: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportDataDialog({ importData }: Props) {
|
/**
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
||||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
||||||
|
*/
|
||||||
|
function isFilePath(value: string): boolean {
|
||||||
return (
|
return (
|
||||||
<VStack space={5} className="pb-4">
|
value.startsWith("/") ||
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
value.startsWith("./") ||
|
||||||
|
value.startsWith("../") ||
|
||||||
|
value.startsWith("~/") ||
|
||||||
|
value.startsWith("\\\\") ||
|
||||||
|
/^[a-zA-Z]:[\\/]/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<VStack space={1}>
|
function fileName(path: string): string {
|
||||||
<ul className="list-disc pl-5">
|
return path.split(/[/\\]/).at(-1) || path;
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
}
|
||||||
<li>Postman Collection v2, v2.1</li>
|
|
||||||
<li>Insomnia v4+</li>
|
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||||
<li>Swagger 2.0</li>
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
<li>
|
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||||
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||||
</li>
|
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||||
</ul>
|
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||||
</VStack>
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
<VStack space={2}>
|
const trimmedSource = source?.trim() ?? "";
|
||||||
<SelectFile
|
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
||||||
filePath={filePath ?? null}
|
|
||||||
onChange={({ filePath }) => setFilePath(filePath)}
|
const selectSource = (value: string) => {
|
||||||
/>
|
setSource(value);
|
||||||
{filePath && (
|
// Remount the input so it shows the path of the newly-picked file
|
||||||
<Button
|
setForceUpdateKey((k) => k + 1);
|
||||||
color="primary"
|
};
|
||||||
disabled={!filePath || isLoading}
|
|
||||||
isLoading={isLoading}
|
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
||||||
size="sm"
|
useEffect(() => {
|
||||||
onClick={async () => {
|
return platform.window.onDragDrop((event) => {
|
||||||
|
if (event.type === "over") {
|
||||||
|
const p = event.position;
|
||||||
|
const r = ref.current?.getBoundingClientRect();
|
||||||
|
if (r == null) return;
|
||||||
|
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
||||||
|
} else if (event.type === "drop" && isHovering) {
|
||||||
|
const p = event.paths[0];
|
||||||
|
if (p) selectSource(p);
|
||||||
|
setIsHovering(false);
|
||||||
|
} else {
|
||||||
|
setIsHovering(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isHovering, setSource]);
|
||||||
|
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||||
|
if (selected == null) return;
|
||||||
|
selectSource(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await importData(filePath);
|
if (filePath != null) {
|
||||||
|
await importFile(filePath);
|
||||||
|
} else {
|
||||||
|
await importUrl(trimmedSource);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack ref={ref} space={4} className="pb-4">
|
||||||
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectFile}
|
||||||
|
className={classNames(
|
||||||
|
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||||
|
"flex flex-col items-center gap-1 text-center",
|
||||||
|
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||||
|
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||||
|
<div className="h-6 w-full flex items-center justify-center">
|
||||||
|
{filePath == null ? (
|
||||||
|
<div className="text-text">
|
||||||
|
<strong className="font-semibold">Choose a file</strong> or drag it here
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
||||||
|
{fileName(filePath)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-subtlest">
|
||||||
|
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<VStack space={2}>
|
||||||
|
<PlainInput
|
||||||
|
label="Or enter a file path or URL"
|
||||||
|
size="sm"
|
||||||
|
placeholder="https://example.com/openapi.json"
|
||||||
|
defaultValue={source ?? ""}
|
||||||
|
forceUpdateKey={String(forceUpdateKey)}
|
||||||
|
onChange={setSource}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={trimmedSource === "" || isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImport}
|
||||||
>
|
>
|
||||||
{isLoading ? "Importing" : "Import"}
|
{isLoading ? "Importing" : "Import"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
@@ -14,6 +13,7 @@ import type { ButtonProps } from "./core/Button";
|
|||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ function getDetail(
|
|||||||
leftSlot: <Icon icon="gift" />,
|
leftSlot: <Icon icon="gift" />,
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||||
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
||||||
onSelect: () => openUrl(data.data.changesUrl ?? ""),
|
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
@@ -63,7 +63,7 @@ function getDetail(
|
|||||||
leftSlot: <Icon icon="refresh" />,
|
leftSlot: <Icon icon="refresh" />,
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||||
hidden: data.data.changesUrl == null,
|
hidden: data.data.changesUrl == null,
|
||||||
onSelect: () => openUrl(data.data.billingUrl),
|
onSelect: () => platform.openUrl(data.data.billingUrl),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Enter License Key",
|
label: "Enter License Key",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -12,8 +11,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
|||||||
const src = useQuery({
|
const src = useQuery({
|
||||||
queryKey: ["local-image", srcPath],
|
queryKey: ["local-image", srcPath],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const p = await resolveResource(srcPath);
|
const p = await platform.files.resolveResource(srcPath);
|
||||||
return convertFileSrc(p);
|
return platform.files.url(p);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
|||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||||
onSelect: () => onPinnedResponseId(r.id),
|
onSelect: () => {
|
||||||
|
onPinnedResponseId(r.id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -6,6 +5,7 @@ import { CountBadge } from "./core/CountBadge";
|
|||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -45,7 +45,7 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||||
icon="external_link"
|
icon="external_link"
|
||||||
onClick={() => openUrl(response.url)}
|
onClick={() => platform.openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -26,7 +26,7 @@ export function ResponseInfo({ response }: Props) {
|
|||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
|
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
|
||||||
icon="external_link"
|
icon="external_link"
|
||||||
onClick={() => openUrl(response.url)}
|
onClick={() => platform.openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
@@ -41,7 +40,7 @@ export function SelectFile({
|
|||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
const filePath = await open({
|
const filePath = await platform.dialog.open({
|
||||||
title: directory ? "Select Folder" : "Select File",
|
title: directory ? "Select Folder" : "Select File",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
directory,
|
directory,
|
||||||
@@ -64,32 +63,21 @@ export function SelectFile({
|
|||||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
||||||
// as browser drag-n-drop.
|
// as browser drag-n-drop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
return platform.window.onDragDrop((event) => {
|
||||||
const setup = async () => {
|
if (event.type === "over") {
|
||||||
const webview = getCurrentWebviewWindow();
|
const p = event.position;
|
||||||
unlisten = await webview.onDragDropEvent((event) => {
|
|
||||||
if (event.payload.type === "over") {
|
|
||||||
const p = event.payload.position;
|
|
||||||
const r = ref.current?.getBoundingClientRect();
|
const r = ref.current?.getBoundingClientRect();
|
||||||
if (r == null) return;
|
if (r == null) return;
|
||||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||||
console.log("IS OVER", isOver);
|
|
||||||
setIsHovering(isOver);
|
setIsHovering(isOver);
|
||||||
} else if (event.payload.type === "drop" && isHovering) {
|
} else if (event.type === "drop" && isHovering) {
|
||||||
console.log("User dropped", event.payload.paths);
|
const p = event.paths[0];
|
||||||
const p = event.payload.paths[0];
|
|
||||||
if (p) onChange({ filePath: p, contentType: null });
|
if (p) onChange({ filePath: p, contentType: null });
|
||||||
setIsHovering(false);
|
setIsHovering(false);
|
||||||
} else {
|
} else {
|
||||||
console.log("File drop cancelled");
|
|
||||||
setIsHovering(false);
|
setIsHovering(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
setup().catch(console.error);
|
|
||||||
return () => {
|
|
||||||
if (unlisten) unlisten();
|
|
||||||
};
|
|
||||||
}, [isHovering, onChange]);
|
}, [isHovering, onChange]);
|
||||||
|
|
||||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useSearch } from "@tanstack/react-router";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -21,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
|||||||
import { SettingsTheme } from "./SettingsTheme";
|
import { SettingsTheme } from "./SettingsTheme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
tab?: SettingsTabWithSubtab | null;
|
||||||
|
/** Set when Settings is in a dialog rather than owning a window. */
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,25 +43,19 @@ const tabs = [
|
|||||||
TAB_LICENSE,
|
TAB_LICENSE,
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
export type SettingsTab = (typeof tabs)[number];
|
||||||
|
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||||
|
|
||||||
export default function Settings({ hide }: Props) {
|
export default function Settings({ tab, hide }: Props) {
|
||||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
|
||||||
// Parse tab and subtab (e.g., "plugins:installed")
|
// Parse tab and subtab (e.g., "plugins:installed")
|
||||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
const plugins = useAtomValue(pluginsAtom);
|
||||||
const licenseCheck = useLicense();
|
const licenseCheck = useLicense();
|
||||||
|
|
||||||
// Close settings window on escape
|
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||||
useKeyPressEvent("Escape", async () => {
|
useKeyPressEvent("Escape", async () => {
|
||||||
if (hide != null) {
|
if (hide == null) await platform.window.close();
|
||||||
// It's being shown in a dialog, so close the dialog
|
|
||||||
hide();
|
|
||||||
} else {
|
|
||||||
// It's being shown in a window, so close the window
|
|
||||||
await getCurrentWebviewWindow().close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +69,7 @@ export default function Settings({ hide }: Props) {
|
|||||||
onlyXWindowControl
|
onlyXWindowControl
|
||||||
size="md"
|
size="md"
|
||||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||||
osType={type()}
|
osType={platform.osType()}
|
||||||
hideWindowControls={settings.hideWindowControls}
|
hideWindowControls={settings.hideWindowControls}
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
useNativeTitlebar={settings.useNativeTitlebar}
|
||||||
interfaceScale={settings.interfaceScale}
|
interfaceScale={settings.interfaceScale}
|
||||||
@@ -85,13 +79,13 @@ export default function Settings({ hide }: Props) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||||
>
|
>
|
||||||
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultValue={mainTab || tabFromQuery}
|
defaultValue={mainTab}
|
||||||
addBorders
|
addBorders
|
||||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -9,6 +8,7 @@ import { CargoFeature } from "../CargoFeature";
|
|||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import {
|
import {
|
||||||
ModelSettingRowBoolean,
|
ModelSettingRowBoolean,
|
||||||
ModelSettingSelectControl,
|
ModelSettingSelectControl,
|
||||||
@@ -112,11 +112,22 @@ export function SettingsGeneral() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</CargoFeature>
|
</CargoFeature>
|
||||||
|
|
||||||
|
<CargoFeature feature="license">
|
||||||
|
<SettingsSection title="Feedback">
|
||||||
|
<SettingRowBoolean
|
||||||
|
title="Prompt for feedback"
|
||||||
|
description="Show rare one-time prompts asking how new features are working."
|
||||||
|
checked={settings.promptFeedback}
|
||||||
|
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
{showWorkspaceSettingsMovedBanner && (
|
||||||
<DismissibleBanner
|
<DismissibleBanner
|
||||||
id="workspace-settings-moved-2026-06-30"
|
id="workspace-settings-moved-2026-06-30"
|
||||||
color="info"
|
color="info"
|
||||||
className="p-4 max-w-xl mx-auto"
|
className="w-full p-4 max-w-xl mr-auto"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Workspace specific settings have moved to{" "}
|
Workspace specific settings have moved to{" "}
|
||||||
@@ -141,7 +152,7 @@ export function SettingsGeneral() {
|
|||||||
{
|
{
|
||||||
title: revealInFinderText,
|
title: revealInFinderText,
|
||||||
icon: "folder_open",
|
icon: "folder_open",
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +168,7 @@ export function SettingsGeneral() {
|
|||||||
{
|
{
|
||||||
title: revealInFinderText,
|
title: revealInFinderText,
|
||||||
icon: "folder_open",
|
icon: "folder_open",
|
||||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
|||||||
<HotkeyRow
|
<HotkeyRow
|
||||||
key={action}
|
key={action}
|
||||||
action={action}
|
action={action}
|
||||||
currentKeys={hotkeys[action]}
|
currentKeys={hotkeys[action] ?? []}
|
||||||
defaultKeys={defaultHotkeys[action]}
|
defaultKeys={defaultHotkeys[action]}
|
||||||
onSave={async (keys) => {
|
onSave={async (keys) => {
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
const newHotkeys = { ...settings.hotkeys };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
import { useFonts } from "@yaakapp-internal/fonts";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||||
@@ -9,7 +9,7 @@ import { useState } from "react";
|
|||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -176,7 +176,7 @@ export function SettingsInterface() {
|
|||||||
|
|
||||||
<SettingsSection title="Window">
|
<SettingsSection title="Window">
|
||||||
<NativeTitlebarSetting settings={settings} />
|
<NativeTitlebarSetting settings={settings} />
|
||||||
{type() !== "macos" && (
|
{platform.osType() !== "macos" && (
|
||||||
<ModelSettingRowBoolean
|
<ModelSettingRowBoolean
|
||||||
model={settings}
|
model={settings}
|
||||||
modelKey="hideWindowControls"
|
modelKey="hideWindowControls"
|
||||||
@@ -216,7 +216,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||||
await invokeCmd("cmd_restart");
|
await rpc("cmd_restart");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Apply and Restart
|
Apply and Restart
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import { differenceInDays } from "date-fns";
|
import { differenceInDays } from "date-fns";
|
||||||
@@ -12,6 +11,7 @@ import { Button } from "../core/Button";
|
|||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import { PlainInput } from "../core/PlainInput";
|
import { PlainInput } from "../core/PlainInput";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
@@ -135,7 +135,7 @@ function SettingsLicenseCmp() {
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
>
|
>
|
||||||
Direct Support
|
Direct Support
|
||||||
@@ -151,7 +151,7 @@ function SettingsLicenseCmp() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Purchase License
|
Purchase License
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { Plugin } from "@yaakapp-internal/models";
|
import type { Plugin } from "@yaakapp-internal/models";
|
||||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||||
@@ -39,6 +38,7 @@ import { PlainInput } from "../core/PlainInput";
|
|||||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { SelectFile } from "../SelectFile";
|
import { SelectFile } from "../SelectFile";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface SettingsPluginsProps {
|
interface SettingsPluginsProps {
|
||||||
defaultSubtab?: string;
|
defaultSubtab?: string;
|
||||||
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
icon="help"
|
icon="help"
|
||||||
title="View documentation"
|
title="View documentation"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
@@ -13,6 +12,7 @@ import { Dropdown } from "./core/Dropdown";
|
|||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export function SettingsDropdown() {
|
export function SettingsDropdown() {
|
||||||
const exportData = useExportData();
|
const exportData = useExportData();
|
||||||
@@ -62,7 +62,7 @@ export function SettingsDropdown() {
|
|||||||
{
|
{
|
||||||
label: "Create Run Button",
|
label: "Create Run Button",
|
||||||
leftSlot: <Icon icon="rocket" />,
|
leftSlot: <Icon icon="rocket" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/button/new"),
|
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
|
||||||
},
|
},
|
||||||
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
||||||
{
|
{
|
||||||
@@ -78,26 +78,26 @@ export function SettingsDropdown() {
|
|||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
hidden: appInfo.cliVersion != null,
|
hidden: appInfo.cliVersion != null,
|
||||||
leftSlot: <Icon icon="square_terminal" />,
|
leftSlot: <Icon icon="square_terminal" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/docs/cli"),
|
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Feedback",
|
label: "Feedback",
|
||||||
leftSlot: <Icon icon="chat" />,
|
leftSlot: <Icon icon="chat" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/feedback"),
|
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Changelog",
|
label: "Changelog",
|
||||||
leftSlot: <Icon icon="cake" />,
|
leftSlot: <Icon icon="cake" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ import { getFolderActions } from "../hooks/useFolderActions";
|
|||||||
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
import { useHotKey } from "../hooks/useHotKey";
|
||||||
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
||||||
import { useListenToTauriEvent } from "../hooks/useListenToTauriEvent";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||||
import { getModelAncestors } from "../hooks/useModelAncestors";
|
import { getModelAncestors } from "../hooks/useModelAncestors";
|
||||||
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||||
@@ -112,6 +113,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||||
const filterText = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
const filterRef = useRef<InputHandle>(null);
|
const filterRef = useRef<InputHandle>(null);
|
||||||
@@ -130,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
if (!didFocus) filterRef.current?.focus();
|
if (!didFocus) filterRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus any new sidebar models when created
|
// Focus new sidebar models created by the user in this window. Writes from other
|
||||||
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
|
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
|
||||||
if (!isSidebarLeafModel(payload.model)) return;
|
// the selection.
|
||||||
if (!(payload.change.type === "upsert" && payload.change.created)) return;
|
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
|
for (const payload of payloads) {
|
||||||
|
if (payload.updateSource.type !== "window") continue;
|
||||||
|
if (payload.updateSource.label !== platform.window.label) continue;
|
||||||
|
if (!isSidebarLeafModel(payload.model)) continue;
|
||||||
|
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
|
||||||
treeRef.current?.selectItem(payload.model.id, true);
|
treeRef.current?.selectItem(payload.model.id, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -724,7 +732,11 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sidebar;
|
// Memoized so route navigations (which re-render the workspace layout) don't
|
||||||
|
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
|
||||||
|
// very expensive: it re-renders DndContext, whose context churn re-renders
|
||||||
|
// every visible TreeItem regardless of their memo comparators.
|
||||||
|
export default memo(Sidebar);
|
||||||
|
|
||||||
function getGitContextMenuItems({
|
function getGitContextMenuItems({
|
||||||
items,
|
items,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { readDir } from "@tauri-apps/plugin-fs";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
@@ -6,6 +5,7 @@ import { Button } from "./core/Button";
|
|||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
export interface SyncToFilesystemSettingProps {
|
||||||
layout?: "form" | "settings";
|
layout?: "form" | "settings";
|
||||||
@@ -24,7 +24,7 @@ export function SyncToFilesystemSetting({
|
|||||||
|
|
||||||
const handleFilePathChange = async (filePath: string | null) => {
|
const handleFilePathChange = async (filePath: string | null) => {
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
const files = await readDir(filePath);
|
const files = await platform.files.readDir(filePath);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
setSyncDir(filePath);
|
setSyncDir(filePath);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
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";
|
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
pairs: HttpRequest["headers"];
|
pairs: EditablePair[];
|
||||||
stateKey: PairEditorProps["stateKey"];
|
stateKey: PairEditorProps["stateKey"];
|
||||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
onChange: PairEditorProps["onChange"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
@@ -18,17 +22,15 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|||||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } 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 TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
// actually changes. The active request's own URL is included, but exact matches
|
||||||
const requests = get(allRequestsAtom);
|
// are filtered out at completion time by genericCompletion.
|
||||||
return requests
|
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||||
.filter((r) => r.id !== activeRequestId)
|
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -83,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("websocket_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
return [
|
return [
|
||||||
@@ -127,23 +139,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => ({
|
() => getUrlCompletionConfig(autocompleteUrls),
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
const handleConnect = useCallback(async () => {
|
||||||
|
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||||
await connectWebsocket({
|
await connectWebsocket({
|
||||||
requestId: activeRequest.id,
|
requestId: activeRequest.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -153,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (connection == null) return;
|
if (connection == null) return;
|
||||||
|
await flushAllModelWrites(); // The backend reads the message from the DB
|
||||||
await sendWebsocket({
|
await sendWebsocket({
|
||||||
connectionId: connection?.id,
|
connectionId: connection?.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -165,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -257,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_MESSAGE}>
|
<TabContent value={TAB_MESSAGE}>
|
||||||
@@ -269,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
heightMode={fullHeight ? "full" : "auto"}
|
heightMode={fullHeight ? "full" : "auto"}
|
||||||
defaultValue={activeRequest.message}
|
defaultValue={activeRequest.message}
|
||||||
language={messageLanguage}
|
language={messageLanguage}
|
||||||
onChange={(message) => patchModel(activeRequest, { message })}
|
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||||
stateKey={`json.${activeRequest.id}`}
|
stateKey={`json.${activeRequest.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
@@ -286,7 +291,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
className="font-sans text-xl! px-0!"
|
className="font-sans text-xl! px-0!"
|
||||||
containerClassName="border-0"
|
containerClassName="border-0"
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
placeholder={resolvedModelName(activeRequest)}
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
@@ -294,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
defaultValue={activeRequest.description}
|
defaultValue={activeRequest.description}
|
||||||
stateKey={`description.${activeRequest.id}`}
|
stateKey={`description.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
onChange={(description) => patchModel(activeRequest, { description })}
|
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -53,7 +53,7 @@ export function Workspace() {
|
|||||||
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const osType = type();
|
const osType = platform.osType();
|
||||||
const [width, setWidth] = useSidebarWidth();
|
const [width, setWidth] = useSidebarWidth();
|
||||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -16,6 +14,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
|||||||
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
||||||
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { importData } from "../lib/importData";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { revealInFinderText } from "../lib/reveal";
|
import { revealInFinderText } from "../lib/reveal";
|
||||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||||
@@ -26,6 +25,7 @@ import { Icon } from "@yaakapp-internal/ui";
|
|||||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
label: "Open Folder",
|
label: "Open Folder",
|
||||||
leftSlot: <Icon icon="folder_open" />,
|
leftSlot: <Icon icon="folder_open" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
const dir = await open({
|
const dir = await platform.dialog.open({
|
||||||
title: "Select Workspace Directory",
|
title: "Select Workspace Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -90,6 +90,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
onSelect: openCloneGitRepositoryDialog,
|
onSelect: openCloneGitRepositoryDialog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Import Data",
|
||||||
|
leftSlot: <Icon icon="folder_input" />,
|
||||||
|
onSelect: () => importData.mutate(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -114,7 +119,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="folder_symlink" />,
|
leftSlot: <Icon icon="folder_symlink" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
if (workspaceMeta?.settingSyncDir == null) return;
|
if (workspaceMeta?.settingSyncDir == null) return;
|
||||||
await revealItemInDir(workspaceMeta.settingSyncDir);
|
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CookieDropdown } from "./CookieDropdown";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
||||||
import { ImportCurlButton } from "./ImportCurlButton";
|
import { ImportCurl } from "./ImportCurl";
|
||||||
import { LicenseBadge } from "./LicenseBadge";
|
import { LicenseBadge } from "./LicenseBadge";
|
||||||
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
||||||
import { SettingsDropdown } from "./SettingsDropdown";
|
import { SettingsDropdown } from "./SettingsDropdown";
|
||||||
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
|
|||||||
<RecentRequestsDropdown />
|
<RecentRequestsDropdown />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
||||||
<ImportCurlButton />
|
<ImportCurl />
|
||||||
{showEncryptionSetup ? (
|
{showEncryptionSetup ? (
|
||||||
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
||||||
Enter Encryption Key
|
Enter Encryption Key
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ import { fireAndForget } from "../../lib/fireAndForget";
|
|||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Hotkey } from "./Hotkey";
|
import { Hotkey } from "./Hotkey";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
import type { SeparatorAction } from "./Separator";
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: "separator";
|
type: "separator";
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** A control shown beside the label, eg. revealing the labelled file on disk. */
|
||||||
|
action?: SeparatorAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownItemContent = {
|
export type DropdownItemContent = {
|
||||||
@@ -66,6 +70,12 @@ export type DropdownItemDefault = {
|
|||||||
submenu?: DropdownItem[];
|
submenu?: DropdownItem[];
|
||||||
/** If true, submenu opens on click instead of hover */
|
/** If true, submenu opens on click instead of hover */
|
||||||
submenuOpenOnClick?: boolean;
|
submenuOpenOnClick?: boolean;
|
||||||
|
/**
|
||||||
|
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
|
||||||
|
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
|
||||||
|
* a dedicated button on the right that opens the submenu.
|
||||||
|
*/
|
||||||
|
submenuTrigger?: "row" | "button";
|
||||||
icon?: IconProps["icon"];
|
icon?: IconProps["icon"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,23 +243,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
|||||||
|
|
||||||
export interface ContextMenuProps {
|
export interface ContextMenuProps {
|
||||||
triggerPosition: { x: number; y: number } | null;
|
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;
|
className?: string;
|
||||||
items: DropdownProps["items"];
|
items: DropdownProps["items"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||||
{ triggerPosition, className, items, onClose },
|
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const triggerShape = useMemo(
|
const triggerShape = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
|
triggerRect ?? {
|
||||||
top: triggerPosition?.y ?? 0,
|
top: triggerPosition?.y ?? 0,
|
||||||
bottom: triggerPosition?.y ?? 0,
|
bottom: triggerPosition?.y ?? 0,
|
||||||
left: triggerPosition?.x ?? 0,
|
left: triggerPosition?.x ?? 0,
|
||||||
right: triggerPosition?.x ?? 0,
|
right: triggerPosition?.x ?? 0,
|
||||||
}),
|
},
|
||||||
[triggerPosition],
|
[triggerPosition, triggerRect],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (triggerPosition == null) return null;
|
if (triggerPosition == null) return null;
|
||||||
@@ -259,9 +286,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
|||||||
isOpen={true} // Always open because we return null if not
|
isOpen={true} // Always open because we return null if not
|
||||||
className={className}
|
className={className}
|
||||||
defaultSelectedIndex={null}
|
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}
|
ref={ref}
|
||||||
items={items}
|
items={items}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
triggerRef={triggerRef}
|
||||||
triggerShape={triggerShape}
|
triggerShape={triggerShape}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -277,7 +308,9 @@ interface MenuProps {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
items: DropdownItem[];
|
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;
|
isSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,9 +512,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
if (!item.keepOpenOnSelect) {
|
||||||
|
handleCloseAll();
|
||||||
|
} else if (isSubmenu) {
|
||||||
|
// Keep the parent menu open, but close this submenu — its items may no
|
||||||
|
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleCloseAll, setSelectedIndex],
|
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -606,7 +645,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
const item = filteredItems[selectedIndex ?? -1];
|
const item = filteredItems[selectedIndex ?? -1];
|
||||||
if (!item || item.type === "separator" || item.type === "content") return;
|
if (!item || item.type === "separator" || item.type === "content") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (item.submenu) {
|
if (item.submenu && item.submenuTrigger !== "button") {
|
||||||
const parent = document.activeElement as HTMLButtonElement;
|
const parent = document.activeElement as HTMLButtonElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||||
@@ -625,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
clearTimeout(submenuTimeoutRef.current);
|
clearTimeout(submenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.submenu && !item.submenuOpenOnClick) {
|
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
|
||||||
setActiveSubmenu({ item, parent });
|
setActiveSubmenu({ item, parent });
|
||||||
} else if (activeSubmenu) {
|
} else if (activeSubmenu && activeSubmenu.item !== item) {
|
||||||
|
// Hovering the row that owns the open submenu must not dismiss it — the
|
||||||
|
// pointer travels across the row on its way to a button-triggered submenu
|
||||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||||
const submenuEl = submenuRef.current;
|
const submenuEl = submenuRef.current;
|
||||||
if (!submenuEl || !activeSubmenu) {
|
if (!submenuEl || !activeSubmenu) {
|
||||||
@@ -753,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
||||||
key={i}
|
key={i}
|
||||||
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
||||||
|
action={item.action}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Separator>
|
</Separator>
|
||||||
@@ -774,6 +816,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleItemHover}
|
onHover={handleItemHover}
|
||||||
|
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
||||||
// oxlint-disable-next-line no-array-index-key -- It's fine
|
// oxlint-disable-next-line no-array-index-key -- It's fine
|
||||||
key={i}
|
key={i}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -845,6 +888,7 @@ interface MenuItemProps {
|
|||||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||||
onFocus: (item: DropdownItemDefault) => void;
|
onFocus: (item: DropdownItemDefault) => void;
|
||||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
|
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
isParentOfActiveSubmenu?: boolean;
|
isParentOfActiveSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
@@ -856,6 +900,7 @@ function MenuItem({
|
|||||||
onHover,
|
onHover,
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onOpenSubmenu,
|
||||||
isParentOfActiveSubmenu,
|
isParentOfActiveSubmenu,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
}: MenuItemProps) {
|
||||||
@@ -891,19 +936,22 @@ function MenuItem({
|
|||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rightSlot = item.submenu ? (
|
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
||||||
|
|
||||||
|
const rightSlot =
|
||||||
|
item.submenu && !hasButtonSubmenu ? (
|
||||||
<Icon icon="chevron_right" color="secondary" />
|
<Icon icon="chevron_right" color="secondary" />
|
||||||
) : (
|
) : (
|
||||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
ref={initRef}
|
ref={initRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
||||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -924,6 +972,7 @@ function MenuItem({
|
|||||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
||||||
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||||
|
hasButtonSubmenu && "pr-8",
|
||||||
item.color === "danger" && "text-danger!",
|
item.color === "danger" && "text-danger!",
|
||||||
item.color === "primary" && "text-primary!",
|
item.color === "primary" && "text-primary!",
|
||||||
item.color === "success" && "text-success!",
|
item.color === "success" && "text-success!",
|
||||||
@@ -936,6 +985,52 @@ function MenuItem({
|
|||||||
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasButtonSubmenu) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submenu trigger overlays the row as a sibling (not a child) because the row is
|
||||||
|
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
|
||||||
|
// row keeps its focus highlight while the mouse is over the trigger.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative grid group/menuitem"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
const el = buttonRef.current;
|
||||||
|
if (el == null) return;
|
||||||
|
onHover(item, el);
|
||||||
|
el.focus();
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => buttonRef.current?.blur()}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"absolute right-1.5 inset-y-0 flex items-center",
|
||||||
|
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
color="custom"
|
||||||
|
size="2xs"
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="ellipsis_vertical"
|
||||||
|
iconColor="secondary"
|
||||||
|
title="More actions"
|
||||||
|
className="h-full! w-7!"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent the trigger from stealing focus, which would unhighlight the row
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSubmenu(item, e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemHotKeyProps {
|
interface MenuItemHotKeyProps {
|
||||||
|
|||||||
@@ -121,6 +121,24 @@
|
|||||||
@apply cursor-default;
|
@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 {
|
.fn {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
.fn-inner {
|
.fn-inner {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs";
|
|||||||
import { vim } from "@replit/codemirror-vim";
|
import { vim } from "@replit/codemirror-vim";
|
||||||
|
|
||||||
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
||||||
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import type { EditorKeymap } from "@yaakapp-internal/models";
|
import type { EditorKeymap } from "@yaakapp-internal/models";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||||
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { GraphQLSchema } from "graphql";
|
import type { GraphQLSchema } from "graphql";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { md5 } from "js-md5";
|
|
||||||
import type { ReactNode, RefObject } from "react";
|
import type { ReactNode, RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
@@ -33,6 +33,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"
|
|||||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||||
|
import { docFingerprint } from "../../../lib/docFingerprint";
|
||||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||||
import { jotaiStore } from "../../../lib/jotai";
|
import { jotaiStore } from "../../../lib/jotai";
|
||||||
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
|
|||||||
import "./Editor.css";
|
import "./Editor.css";
|
||||||
import {
|
import {
|
||||||
baseExtensions,
|
baseExtensions,
|
||||||
|
editableExtensions,
|
||||||
getLanguageExtension,
|
getLanguageExtension,
|
||||||
multiLineExtensions,
|
multiLineExtensions,
|
||||||
readonlyExtensions,
|
readonlyExtensions,
|
||||||
@@ -288,7 +290,7 @@ function EditorInner({
|
|||||||
function configureReadOnly() {
|
function configureReadOnly() {
|
||||||
if (cm.current === null) return;
|
if (cm.current === null) return;
|
||||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
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
|
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||||
if (current === next) return;
|
if (current === next) return;
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ function EditorInner({
|
|||||||
const initEditorRef = useCallback(
|
const initEditorRef = useCallback(
|
||||||
function initEditorRef(container: HTMLDivElement | null) {
|
function initEditorRef(container: HTMLDivElement | null) {
|
||||||
if (container === null) {
|
if (container === null) {
|
||||||
|
flushCachedEditorState(stateKey);
|
||||||
cm.current?.view.destroy();
|
cm.current?.view.destroy();
|
||||||
cm.current = null;
|
cm.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -410,7 +413,7 @@ function EditorInner({
|
|||||||
keymapCompartment.current.of(
|
keymapCompartment.current.of(
|
||||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||||
),
|
),
|
||||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
|
||||||
...getExtensions({
|
...getExtensions({
|
||||||
container,
|
container,
|
||||||
singleLine,
|
singleLine,
|
||||||
@@ -430,7 +433,7 @@ function EditorInner({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
|
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
|
||||||
|
|
||||||
const doc = `${defaultValue ?? ""}`;
|
const doc = `${defaultValue ?? ""}`;
|
||||||
const config: EditorStateConfig = { extensions, doc };
|
const config: EditorStateConfig = { extensions, doc };
|
||||||
@@ -639,7 +642,7 @@ function getExtensions({
|
|||||||
onChange.current?.(update.state.doc.toString());
|
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;
|
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) {
|
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||||
if (!stateKey || state == null) return;
|
if (!stateKey || state == null) return;
|
||||||
const stateObj = state.toJSON(stateFields);
|
const stateObj = state.toJSON(stateFields);
|
||||||
|
|
||||||
// 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.
|
// 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;
|
stateObj.doc = undefined;
|
||||||
|
|
||||||
try {
|
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;
|
if (stateKey == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
|
|||||||
const { docHash, ...state } = JSON.parse(stateStr);
|
const { docHash, ...state } = JSON.parse(stateStr);
|
||||||
|
|
||||||
// Ensure the doc matches the one that was used to save the state
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
|
|||||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||||
import type { EditorProps } from "./Editor";
|
import type { EditorProps } from "./Editor";
|
||||||
import { jsonParseLinter } from "./json-lint";
|
import { jsonParseLinter } from "./json-lint";
|
||||||
|
import { largeValues, mediaValues } from "./largeValues";
|
||||||
import { pairs } from "./pairs/extension";
|
import { pairs } from "./pairs/extension";
|
||||||
import { searchMatchCount } from "./searchMatchCount";
|
import { searchMatchCount } from "./searchMatchCount";
|
||||||
import { text } from "./text/extension";
|
import { text } from "./text/extension";
|
||||||
@@ -258,8 +259,19 @@ export const baseExtensions = [
|
|||||||
export const readonlyExtensions = [
|
export const readonlyExtensions = [
|
||||||
EditorState.readOnly.of(true),
|
EditorState.readOnly.of(true),
|
||||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
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 }) => [
|
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||||
search({ top: true }),
|
search({ top: true }),
|
||||||
searchMatchCount(),
|
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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { defaultBoost } from "./twig/completion";
|
import { defaultBoost } from "./twig/completion";
|
||||||
|
|
||||||
|
export type GenericCompletion = GenericCompletionOption & {
|
||||||
|
apply?: Completion["apply"];
|
||||||
|
};
|
||||||
|
|
||||||
export interface GenericCompletionConfig {
|
export interface GenericCompletionConfig {
|
||||||
minMatch?: number;
|
minMatch?: number;
|
||||||
options: GenericCompletionOption[];
|
options: GenericCompletion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import type { Diagnostic } from "@codemirror/lint";
|
import type { Diagnostic } from "@codemirror/lint";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||||
|
|
||||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||||
|
|
||||||
|
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||||
|
const MESSAGES: Record<string, string> = {
|
||||||
|
InvalidSymbol: "Invalid symbol",
|
||||||
|
InvalidNumberFormat: "Invalid number format",
|
||||||
|
PropertyNameExpected: "Property name expected",
|
||||||
|
ValueExpected: "Value expected",
|
||||||
|
ColonExpected: "Colon expected",
|
||||||
|
CommaExpected: "Comma expected",
|
||||||
|
CloseBraceExpected: "Closing brace expected",
|
||||||
|
CloseBracketExpected: "Closing bracket expected",
|
||||||
|
EndOfFileExpected: "End of file expected",
|
||||||
|
InvalidCommentToken: "Comments are not allowed",
|
||||||
|
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||||
|
UnexpectedEndOfString: "Unexpected end of string",
|
||||||
|
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||||
|
InvalidUnicode: "Invalid unicode sequence",
|
||||||
|
InvalidEscapeCharacter: "Invalid escape character",
|
||||||
|
InvalidCharacter: "Invalid character",
|
||||||
|
};
|
||||||
|
|
||||||
interface JsonLintOptions {
|
interface JsonLintOptions {
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
allowTrailingCommas?: boolean;
|
allowTrailingCommas?: boolean;
|
||||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
|||||||
|
|
||||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||||
return (view: EditorView): Diagnostic[] => {
|
return (view: EditorView): Diagnostic[] => {
|
||||||
try {
|
|
||||||
const doc = view.state.doc.toString();
|
const doc = view.state.doc.toString();
|
||||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||||
jsonLintParse(escapedDoc, {
|
|
||||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
|
||||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!("location" in err)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// const line = location?.start?.line;
|
const errors: ParseError[] = [];
|
||||||
// const column = location?.start?.column;
|
parse(escapedDoc, errors, {
|
||||||
if (err.location.start.offset) {
|
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||||
|
disallowComments: !(options?.allowComments ?? true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||||
|
const error = errors[0];
|
||||||
|
if (error == null) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
from: err.location.start.offset,
|
from: error.offset,
|
||||||
to: err.location.start.offset,
|
to: error.offset + error.length,
|
||||||
severity: "error",
|
severity: "error",
|
||||||
message: err.message,
|
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
const protocolOptions: GenericCompletionOption[] = [
|
||||||
options: [
|
|
||||||
{ label: "http://", type: "constant" },
|
{ label: "http://", type: "constant" },
|
||||||
{ label: "https://", type: "constant" },
|
{ label: "https://", type: "constant" },
|
||||||
],
|
];
|
||||||
minMatch: 1,
|
|
||||||
});
|
export function getUrlCompletionConfig(
|
||||||
|
options: GenericCompletionOption[],
|
||||||
|
minMatch = 3,
|
||||||
|
): GenericCompletionConfig {
|
||||||
|
const urlOptions = [
|
||||||
|
...protocolOptions,
|
||||||
|
...options.filter(
|
||||||
|
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
minMatch,
|
||||||
|
options: urlOptions.map<GenericCompletion>((option) => ({
|
||||||
|
...option,
|
||||||
|
apply: applyUrlCompletion,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUrlCompletion(
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
const isProtocol = /^https?:\/\/$/.test(completion.label);
|
||||||
|
const replaceTo = isProtocol
|
||||||
|
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
|
||||||
|
: view.state.doc.length;
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
...insertCompletionText(view.state, completion.label, from, replaceTo),
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||||
|
import { HotkeyList } from "./HotkeyList";
|
||||||
|
|
||||||
|
vi.mock("./Hotkey", () => ({
|
||||||
|
Hotkey: ({ action }: { action: HotkeyAction }) =>
|
||||||
|
action === "sidebar.selected.move" ? null : <span>{action}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./HotkeyLabel", () => ({
|
||||||
|
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HotkeyList", () => {
|
||||||
|
test("keeps a grid cell for actions without a shortcut", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain(
|
||||||
|
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
|||||||
{hotkeys.map((hotkey) => (
|
{hotkeys.map((hotkey) => (
|
||||||
<Fragment key={hotkey}>
|
<Fragment key={hotkey}>
|
||||||
<HotkeyLabel className="truncate" action={hotkey} />
|
<HotkeyLabel className="truncate" action={hotkey} />
|
||||||
<Hotkey className="ml-4" action={hotkey} />
|
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||||
|
<div className="ml-4">
|
||||||
|
<Hotkey action={hotkey} />
|
||||||
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{bottomSlot}
|
{bottomSlot}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button } from "./Button";
|
|||||||
import type { DropdownItem } from "./Dropdown";
|
import type { DropdownItem } from "./Dropdown";
|
||||||
import { Dropdown } from "./Dropdown";
|
import { Dropdown } from "./Dropdown";
|
||||||
import type { EditorProps } from "./Editor/Editor";
|
import type { EditorProps } from "./Editor/Editor";
|
||||||
|
import type { GenericCompletion } from "./Editor/genericCompletion";
|
||||||
import { Editor } from "./Editor/LazyEditor";
|
import { Editor } from "./Editor/LazyEditor";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
import { IconTooltip } from "./IconTooltip";
|
||||||
@@ -63,6 +64,13 @@ export type InputProps = Pick<
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
rightSlot?: ReactNode;
|
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";
|
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
||||||
stateKey: EditorProps["stateKey"];
|
stateKey: EditorProps["stateKey"];
|
||||||
extraExtensions?: EditorProps["extraExtensions"];
|
extraExtensions?: EditorProps["extraExtensions"];
|
||||||
@@ -115,6 +123,7 @@ function BaseInput({
|
|||||||
readOnly,
|
readOnly,
|
||||||
required,
|
required,
|
||||||
rightSlot,
|
rightSlot,
|
||||||
|
showOptionsPicker,
|
||||||
size = "md",
|
size = "md",
|
||||||
stateKey,
|
stateKey,
|
||||||
tint,
|
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(
|
const setEditorRef = useCallback(
|
||||||
(h: EditorView | null) => {
|
(h: EditorView | null) => {
|
||||||
editorRef.current = h;
|
editorRef.current = h;
|
||||||
@@ -318,6 +338,7 @@ function BaseInput({
|
|||||||
editorClassName,
|
editorClassName,
|
||||||
multiLine && size === "md" && "py-1.5",
|
multiLine && size === "md" && "py-1.5",
|
||||||
multiLine && size === "sm" && "py-1",
|
multiLine && size === "sm" && "py-1",
|
||||||
|
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||||
)}
|
)}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
@@ -349,12 +370,55 @@ function BaseInput({
|
|||||||
onClick={() => setObscured((o) => !o)}
|
onClick={() => setObscured((o) => !o)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pickerOptions.length > 0 && (
|
||||||
|
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
|
||||||
|
)}
|
||||||
{rightSlot}
|
{rightSlot}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</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) {
|
function validateRequire(v: string) {
|
||||||
return v.length > 0;
|
return v.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { basename } from "@tauri-apps/api/path";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
||||||
@@ -34,12 +33,22 @@ import { Input } from "./Input";
|
|||||||
import { ensurePairId } from "./PairEditor.util";
|
import { ensurePairId } from "./PairEditor.util";
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||||
import { RadioDropdown } from "./RadioDropdown";
|
import { RadioDropdown } from "./RadioDropdown";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export interface PairEditorHandle {
|
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;
|
focusName(id: string): void;
|
||||||
|
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
|
||||||
focusValue(id: string): void;
|
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 = {
|
export type PairEditorProps = {
|
||||||
allowFileValues?: boolean;
|
allowFileValues?: boolean;
|
||||||
allowMultilineValues?: boolean;
|
allowMultilineValues?: boolean;
|
||||||
@@ -53,7 +62,7 @@ export type PairEditorProps = {
|
|||||||
nameValidate?: InputProps["validate"];
|
nameValidate?: InputProps["validate"];
|
||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
onChange: (pairs: PairWithId[]) => void;
|
onChange: (pairs: PairWithId[]) => void;
|
||||||
pairs: Pair[];
|
pairs: EditablePair[];
|
||||||
stateKey: InputProps["stateKey"];
|
stateKey: InputProps["stateKey"];
|
||||||
setRef?: (n: PairEditorHandle) => void;
|
setRef?: (n: PairEditorHandle) => void;
|
||||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||||
@@ -72,13 +81,35 @@ export type Pair = {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
isFile?: boolean;
|
isFile?: boolean;
|
||||||
readOnlyName?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairWithId = Pair & {
|
export type PairWithId = Pair & {
|
||||||
id: string;
|
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 */
|
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||||
const MAX_INITIAL_PAIRS = 30;
|
const MAX_INITIAL_PAIRS = 30;
|
||||||
|
|
||||||
@@ -106,8 +137,8 @@ export function PairEditor({
|
|||||||
setRef,
|
setRef,
|
||||||
}: PairEditorProps) {
|
}: PairEditorProps) {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||||
const [showAll, toggleShowAll] = useToggle(false);
|
const [showAll, toggleShowAll] = useToggle(false);
|
||||||
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
// 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.
|
// 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 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>(
|
const handle = useMemo<PairEditorHandle>(
|
||||||
() => ({
|
() => ({
|
||||||
focusName(id: string) {
|
focusName(id: string) {
|
||||||
rowsRef.current[id]?.focusName();
|
focusWhenReady(id, "name");
|
||||||
},
|
},
|
||||||
focusValue(id: string) {
|
focusValue(id: string) {
|
||||||
rowsRef.current[id]?.focusValue();
|
focusWhenReady(id, "value");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[focusWhenReady],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initPairEditorRow = useCallback(
|
const initPairEditorRow = useCallback(
|
||||||
@@ -147,7 +200,7 @@ export function PairEditor({
|
|||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
// 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++) {
|
for (let i = 0; i < originalPairs.length; i++) {
|
||||||
const p = originalPairs[i];
|
const p = originalPairs[i];
|
||||||
if (!p) continue; // Make TS happy
|
if (!p) continue; // Make TS happy
|
||||||
@@ -155,6 +208,21 @@ export function PairEditor({
|
|||||||
newPairs.push(ensurePairId(p));
|
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
|
// Add empty last pair if there is none
|
||||||
const lastPair = newPairs[newPairs.length - 1];
|
const lastPair = newPairs[newPairs.length - 1];
|
||||||
if (lastPair == null || !isPairEmpty(lastPair)) {
|
if (lastPair == null || !isPairEmpty(lastPair)) {
|
||||||
@@ -166,10 +234,10 @@ export function PairEditor({
|
|||||||
}, [forceUpdateKey]);
|
}, [forceUpdateKey]);
|
||||||
|
|
||||||
const setPairsAndSave = useCallback(
|
const setPairsAndSave = useCallback(
|
||||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
|
||||||
setPairs((oldPairs) => {
|
setPairs((oldPairs) => {
|
||||||
const pairs = fn(oldPairs);
|
const pairs = fn(oldPairs);
|
||||||
onChange(pairs);
|
onChange(pairs.map(toPairData));
|
||||||
return pairs;
|
return pairs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -177,7 +245,7 @@ export function PairEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(pair: PairWithId) =>
|
(pair: EditablePairWithId) =>
|
||||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||||
[setPairsAndSave],
|
[setPairsAndSave],
|
||||||
);
|
);
|
||||||
@@ -362,14 +430,14 @@ export function PairEditor({
|
|||||||
|
|
||||||
type PairEditorRowProps = {
|
type PairEditorRowProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
pair: PairWithId;
|
pair: EditablePairWithId;
|
||||||
forceFocusNamePairId?: string | null;
|
forceFocusNamePairId?: string | null;
|
||||||
forceFocusValuePairId?: string | null;
|
forceFocusValuePairId?: string | null;
|
||||||
onChange?: (pair: PairWithId) => void;
|
onChange?: (pair: EditablePairWithId) => void;
|
||||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
|
||||||
onFocusName?: (pair: PairWithId) => void;
|
onFocusName?: (pair: EditablePairWithId) => void;
|
||||||
onFocusValue?: (pair: PairWithId) => void;
|
onFocusValue?: (pair: EditablePairWithId) => void;
|
||||||
onSubmit?: (pair: PairWithId) => void;
|
onSubmit?: (pair: EditablePairWithId) => void;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disableDrag?: boolean;
|
disableDrag?: boolean;
|
||||||
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
interface RowHandle {
|
interface RowHandle {
|
||||||
focusName(): void;
|
focusName(): boolean;
|
||||||
focusValue(): void;
|
focusValue(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairEditorRow({
|
export function PairEditorRow({
|
||||||
@@ -436,9 +504,11 @@ export function PairEditorRow({
|
|||||||
const handle = useRef<RowHandle>({
|
const handle = useRef<RowHandle>({
|
||||||
focusName() {
|
focusName() {
|
||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
|
return nameInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
focusValue() {
|
focusValue() {
|
||||||
valueInputRef.current?.focus();
|
valueInputRef.current?.focus();
|
||||||
|
return valueInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,11 +541,37 @@ export function PairEditorRow({
|
|||||||
[onChange, pair],
|
[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(
|
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],
|
[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(
|
const handleChangeValueText = useMemo(
|
||||||
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
@@ -551,6 +647,10 @@ export function PairEditorRow({
|
|||||||
[setDraggableRef, setDroppableRef],
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={handleSetRef}
|
ref={handleSetRef}
|
||||||
@@ -596,7 +696,7 @@ export function PairEditorRow({
|
|||||||
stateKey={`name.${pair.id}.${stateKey}`}
|
stateKey={`name.${pair.id}.${stateKey}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
wrapLines={false}
|
wrapLines={false}
|
||||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
readOnly={isDraggingGlobal}
|
||||||
size="sm"
|
size="sm"
|
||||||
required={!isLast && !!pair.enabled && !!pair.value}
|
required={!isLast && !!pair.enabled && !!pair.value}
|
||||||
validate={nameValidate}
|
validate={nameValidate}
|
||||||
@@ -606,12 +706,14 @@ export function PairEditorRow({
|
|||||||
defaultValue={pair.name}
|
defaultValue={pair.name}
|
||||||
label="Name"
|
label="Name"
|
||||||
name={`name[${index}]`}
|
name={`name[${index}]`}
|
||||||
|
onBlur={handleBlurName}
|
||||||
onChange={handleChangeName}
|
onChange={handleChangeName}
|
||||||
onFocus={handleFocusName}
|
onFocus={handleFocusName}
|
||||||
placeholder={namePlaceholder ?? "name"}
|
placeholder={namePlaceholder ?? "name"}
|
||||||
autocomplete={nameAutocomplete}
|
autocomplete={nameAutocomplete}
|
||||||
autocompleteVariables={nameAutocompleteVariables}
|
autocompleteVariables={nameAutocompleteVariables}
|
||||||
autocompleteFunctions={nameAutocompleteFunctions}
|
autocompleteFunctions={nameAutocompleteFunctions}
|
||||||
|
showOptionsPicker={showOptionsPicker}
|
||||||
/>
|
/>
|
||||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||||
{pair.isFile ? (
|
{pair.isFile ? (
|
||||||
@@ -656,6 +758,7 @@ export function PairEditorRow({
|
|||||||
autocomplete={valueAutocomplete?.(pair.name)}
|
autocomplete={valueAutocomplete?.(pair.name)}
|
||||||
autocompleteFunctions={valueAutocompleteFunctions}
|
autocompleteFunctions={valueAutocompleteFunctions}
|
||||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||||
|
showOptionsPicker={showOptionsPicker}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -675,8 +778,10 @@ export function PairEditorRow({
|
|||||||
<IconButton
|
<IconButton
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
icon={isLast || disabled ? "empty" : "chevron_down"}
|
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
|
||||||
title="Select form data type"
|
// 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"
|
className="text-text-subtlest"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -746,7 +851,7 @@ function FileActionsDropdown({
|
|||||||
leftSlot: <Icon icon="file_code" />,
|
leftSlot: <Icon icon="file_code" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
console.log("PAIR", pair);
|
console.log("PAIR", pair);
|
||||||
const defaultFilename = await basename(pair.value ?? "");
|
const defaultFilename = await platform.files.basename(pair.value ?? "");
|
||||||
const filename = await showPrompt({
|
const filename = await showPrompt({
|
||||||
id: "filename",
|
id: "filename",
|
||||||
title: "Override Filename",
|
title: "Override Filename",
|
||||||
@@ -800,7 +905,8 @@ function FileActionsDropdown({
|
|||||||
<IconButton
|
<IconButton
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
size="xs"
|
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"
|
title="Select form data type"
|
||||||
className="text-text-subtlest"
|
className="text-text-subtlest"
|
||||||
/>
|
/>
|
||||||
@@ -808,7 +914,7 @@ function FileActionsDropdown({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPair(): PairWithId {
|
function emptyPair(): EditablePairWithId {
|
||||||
return ensurePairId({ enabled: true, name: "", value: "" });
|
return ensurePairId({ enabled: true, name: "", value: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "../../lib/generateId";
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
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") {
|
if (typeof p.id === "string") {
|
||||||
return p as PairWithId;
|
return p as T & PairWithId;
|
||||||
}
|
}
|
||||||
return { ...p, id: p.id ?? generateId() };
|
return { ...p, id: p.id ?? generateId() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
@@ -66,7 +66,7 @@ export function Select<T extends string>({
|
|||||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
{type() === "macos" && !filterable ? (
|
{platform.osType() === "macos" && !filterable ? (
|
||||||
<HStack
|
<HStack
|
||||||
space={2}
|
space={2}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
|
import type { IconProps } from "@yaakapp-internal/ui";
|
||||||
|
import { IconButton } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single control attached to a labelled separator, rendered between the label
|
||||||
|
* and the rule.
|
||||||
|
*
|
||||||
|
* Declared rather than passed as a node so the separator keeps ownership of the
|
||||||
|
* things that are easy to get wrong by hand: matching the label's colour, and
|
||||||
|
* staying out of the rule's way when the label is long.
|
||||||
|
*/
|
||||||
|
export interface SeparatorAction {
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
/** Tooltip and accessible name. Required — the control is icon-only. */
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
action?: SeparatorAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Separator({
|
export function Separator({
|
||||||
@@ -16,15 +34,31 @@ export function Separator({
|
|||||||
dashed,
|
dashed,
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
children,
|
children,
|
||||||
|
action,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||||
)}
|
)}
|
||||||
|
{action && (
|
||||||
|
<IconButton
|
||||||
|
size="2xs"
|
||||||
|
iconSize="xs"
|
||||||
|
className="shrink-0 mr-2 -ml-1"
|
||||||
|
// Forced, because the button itself sets `text-text` at full strength.
|
||||||
|
iconClassName="text-text-subtlest!"
|
||||||
|
icon={action.icon}
|
||||||
|
title={action.title}
|
||||||
|
onClick={action.onClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"opacity-60",
|
"opacity-60",
|
||||||
|
// Keep a stub of the line visible no matter how long the label is —
|
||||||
|
// `w-full` alone gets squeezed to nothing by a wide label.
|
||||||
|
orientation === "horizontal" && "min-w-8",
|
||||||
color == null && "border-border",
|
color == null && "border-border",
|
||||||
color === "primary" && "border-primary",
|
color === "primary" && "border-primary",
|
||||||
color === "secondary" && "border-secondary",
|
color === "secondary" && "border-secondary",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useKey } from "react-use";
|
import { useKey } from "react-use";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ export interface ToastProps {
|
|||||||
action?: (args: { hide: () => void }) => ReactNode;
|
action?: (args: { hide: () => void }) => ReactNode;
|
||||||
icon?: ShowToastRequest["icon"] | null;
|
icon?: ShowToastRequest["icon"] | null;
|
||||||
color?: ShowToastRequest["color"];
|
color?: ShowToastRequest["color"];
|
||||||
|
// Grow with the content (up to the viewport) instead of scrolling internally
|
||||||
|
// past the default max height
|
||||||
|
dynamicHeight?: boolean;
|
||||||
|
// Hide the close button, for toasts that render their own dismiss action.
|
||||||
|
// Escape still closes the toast
|
||||||
|
hideDismiss?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||||
@@ -28,7 +35,47 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
|
|||||||
warning: "alert_triangle",
|
warning: "alert_triangle",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {
|
export function Toast({
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
timeout,
|
||||||
|
action,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
dynamicHeight,
|
||||||
|
hideDismiss,
|
||||||
|
}: ToastProps) {
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const cancelAutoHide = useCallback(() => {
|
||||||
|
if (timeoutRef.current == null) return;
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
setAutoHideCanceled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || timeout == null || autoHideCanceled) return;
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
timeoutRef.current = null;
|
||||||
|
onCloseRef.current();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current == null) return;
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
};
|
||||||
|
}, [autoHideCanceled, open, timeout]);
|
||||||
|
|
||||||
useKey(
|
useKey(
|
||||||
"Escape",
|
"Escape",
|
||||||
() => {
|
() => {
|
||||||
@@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||||
"border border-border shadow-lg w-100",
|
"border border-border shadow-lg w-100",
|
||||||
)}
|
)}
|
||||||
|
onFocusCapture={cancelAutoHide}
|
||||||
|
onKeyDownCapture={cancelAutoHide}
|
||||||
|
onPointerDownCapture={cancelAutoHide}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||||
|
hideDismiss ? "pr-3" : "pr-10",
|
||||||
|
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
|
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||||
<VStack space={2} className="w-full min-w-0">
|
<VStack space={2} className="w-full min-w-0">
|
||||||
<div className="select-auto">{children}</div>
|
<div className="select-auto">{children}</div>
|
||||||
@@ -65,6 +121,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!hideDismiss && (
|
||||||
<IconButton
|
<IconButton
|
||||||
color={color}
|
color={color}
|
||||||
variant="border"
|
variant="border"
|
||||||
@@ -73,8 +130,9 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
icon="x"
|
icon="x"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{timeout != null && (
|
{timeout != null && !autoHideCanceled && (
|
||||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||||
<m.div
|
<m.div
|
||||||
className="bg-surface-highlight h-[3px]"
|
className="bg-surface-highlight h-[3px]"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
|
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
setCommitError(null);
|
setCommitError(null);
|
||||||
try {
|
try {
|
||||||
await commit.mutateAsync({ message });
|
await commit.mutateAsync({ message });
|
||||||
|
trackFeatureUsage("git-sync");
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCommitError(String(err));
|
setCommitError(String(err));
|
||||||
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = await commitAndPush.mutateAsync({ message });
|
const r = await commitAndPush.mutateAsync({ message });
|
||||||
handlePushResult(r);
|
handlePushResult(r);
|
||||||
|
trackFeatureUsage("git-sync");
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast({
|
showErrorToast({
|
||||||
@@ -206,9 +209,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultRatio={0.6}
|
defaultRatio={0.6}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
<div style={style} className="h-full px-4 flex flex-col gap-3">
|
||||||
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
|
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
|
className="min-h-0 flex-1"
|
||||||
storageKey="commit-vertical"
|
storageKey="commit-vertical"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
defaultRatio={0.35}
|
defaultRatio={0.35}
|
||||||
@@ -226,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
/>
|
/>
|
||||||
{externalEntries.find((e) => e.status !== "current") && (
|
{externalEntries.find((e) => e.status !== "current") && (
|
||||||
<>
|
<>
|
||||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
<Separator className="mt-3 mb-1">Other files</Separator>
|
||||||
{externalEntries.map((entry) => (
|
{externalEntries.map((entry) => (
|
||||||
<ExternalTreeNode
|
<ExternalTreeNode
|
||||||
key={entry.relaPath + entry.status}
|
key={entry.relaPath + entry.status}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
relaDir={status.data?.relaDir ?? ""}
|
||||||
onCheck={checkEntry}
|
onCheck={checkEntry}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -239,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
secondSlot={({ style: innerStyle }) => (
|
secondSlot={({ style: innerStyle }) => (
|
||||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
<div
|
||||||
|
style={innerStyle}
|
||||||
|
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
className="text-base! font-sans rounded-md"
|
className="text-base! font-sans rounded-md"
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message..."
|
||||||
@@ -388,15 +396,22 @@ function TreeNodeChildren({
|
|||||||
|
|
||||||
function ExternalTreeNode({
|
function ExternalTreeNode({
|
||||||
entry,
|
entry,
|
||||||
|
relaDir,
|
||||||
onCheck,
|
onCheck,
|
||||||
}: {
|
}: {
|
||||||
entry: GitStatusEntry;
|
entry: GitStatusEntry;
|
||||||
|
relaDir: string;
|
||||||
onCheck: (entry: GitStatusEntry) => void;
|
onCheck: (entry: GitStatusEntry) => void;
|
||||||
}) {
|
}) {
|
||||||
if (entry.status === "current") {
|
if (entry.status === "current") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show paths relative to the sync directory when inside it
|
||||||
|
const displayPath = entry.relaPath.startsWith(`${relaDir}/`)
|
||||||
|
? entry.relaPath.slice(relaDir.length + 1)
|
||||||
|
: entry.relaPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -406,7 +421,7 @@ function ExternalTreeNode({
|
|||||||
title={
|
title={
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||||
<Icon color="secondary" icon="file_code" />
|
<Icon color="secondary" icon="file_code" />
|
||||||
<div className="truncate">{entry.relaPath}</div>
|
<div className="truncate">{displayPath}</div>
|
||||||
<InlineCode
|
<InlineCode
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"py-0 ml-auto bg-transparent w-24 text-center",
|
"py-0 ml-auto bg-transparent w-24 text-center",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||||
@@ -11,9 +12,15 @@ import type { DropdownItem } from "../core/Dropdown";
|
|||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
|
import { RadioDropdown } from "../core/RadioDropdown";
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
|
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||||
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||||
@@ -22,6 +29,12 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
|||||||
request: HttpRequest;
|
request: HttpRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||||
|
|
||||||
|
// How much of the end of a schema filename is pinned when middle-truncating it.
|
||||||
|
// Enough to keep the extension and a little of the name before it.
|
||||||
|
const FILE_NAME_TAIL_CHARS = 12;
|
||||||
|
|
||||||
export function GraphQLEditor(props: Props) {
|
export function GraphQLEditor(props: Props) {
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||||
// key on the request ID as a workaround for now.
|
// key on the request ID as a workaround for now.
|
||||||
@@ -32,31 +45,63 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
>("graphQLAutoIntrospectDisabled", {});
|
||||||
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
const {
|
||||||
|
schema,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
clear,
|
||||||
|
loadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
} = useIntrospectGraphQL(baseRequest, {
|
||||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Last path segment, for display only. The host owns real path semantics; this
|
||||||
|
// just needs something short enough to label the divider with.
|
||||||
|
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
|
||||||
|
|
||||||
|
// Selecting a file is all it takes — the request's source becomes that file,
|
||||||
|
// which is what keeps automatic introspection from overwriting it.
|
||||||
|
const handleLoadFromFile = useCallback(async () => {
|
||||||
|
const selected = await platform.dialog.open({
|
||||||
|
title: "Load GraphQL Schema",
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "GraphQL Schema",
|
||||||
|
extensions: ["graphql", "graphqls", "gql", "json"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
await loadFromFile(selected);
|
||||||
|
}, [loadFromFile]);
|
||||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||||
query: string;
|
query: string;
|
||||||
variables: string | undefined;
|
variables: string | undefined;
|
||||||
|
operationName?: string;
|
||||||
}>(() => {
|
}>(() => {
|
||||||
// Migrate text bodies to GraphQL format
|
// Migrate text bodies to GraphQL format
|
||||||
// NOTE: This is how GraphQL used to be stored
|
// NOTE: This is how GraphQL used to be stored
|
||||||
if ("text" in request.body) {
|
return normalizeGraphQLBody(request.body);
|
||||||
const b = tryParseJson(request.body.text, {});
|
|
||||||
const variables = JSON.stringify(b.variables || undefined, null, 2);
|
|
||||||
return { query: b.query ?? "", variables };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
|
|
||||||
}, [extraEditorProps.forceUpdateKey]);
|
}, [extraEditorProps.forceUpdateKey]);
|
||||||
|
|
||||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||||
|
const parsedOperationNames = useMemo(
|
||||||
|
() => parseGraphQLOperationNames(currentBody.query),
|
||||||
|
[currentBody.query],
|
||||||
|
);
|
||||||
|
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
||||||
|
|
||||||
const handleChangeQuery = useCallback(
|
const handleChangeQuery = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
setCurrentBody(({ variables }) => {
|
setCurrentBody(({ variables, operationName }) => {
|
||||||
const newBody = { query, variables };
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
onChange(newBody);
|
onChange(newBody);
|
||||||
return newBody;
|
return newBody;
|
||||||
});
|
});
|
||||||
@@ -66,8 +111,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
|
|
||||||
const handleChangeVariables = useCallback(
|
const handleChangeVariables = useCallback(
|
||||||
(variables: string) => {
|
(variables: string) => {
|
||||||
setCurrentBody(({ query }) => {
|
setCurrentBody(({ query, operationName }) => {
|
||||||
const newBody = { query, variables: variables || undefined };
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
onChange(newBody);
|
onChange(newBody);
|
||||||
return newBody;
|
return newBody;
|
||||||
});
|
});
|
||||||
@@ -75,9 +120,78 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
[onChange, setCurrentBody],
|
[onChange, setCurrentBody],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleChangeOperationName = useCallback(
|
||||||
|
(operationName: string) => {
|
||||||
|
setCurrentBody(({ query, variables }) => {
|
||||||
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
|
onChange(newBody);
|
||||||
|
return newBody;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange, setCurrentBody],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parsedOperationNames == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
|
||||||
|
const operationName = operationNames[0];
|
||||||
|
if (currentBody.operationName === operationName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentBody(({ query, variables }) => {
|
||||||
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
|
onChange(newBody);
|
||||||
|
return newBody;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentBody.operationName,
|
||||||
|
onChange,
|
||||||
|
operationNames,
|
||||||
|
parsedOperationNames,
|
||||||
|
setCurrentBody,
|
||||||
|
]);
|
||||||
|
|
||||||
const actions = useMemo<EditorProps["actions"]>(
|
const actions = useMemo<EditorProps["actions"]>(
|
||||||
() => [
|
() => [
|
||||||
<div key="actions" className="flex flex-row opacity-100! shadow!">
|
operationNames.length > 0 ? (
|
||||||
|
<div key="operation" className="opacity-100!">
|
||||||
|
<RadioDropdown
|
||||||
|
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||||
|
onChange={handleChangeOperationName}
|
||||||
|
items={[
|
||||||
|
{ type: "separator", label: "Operation Name" },
|
||||||
|
{
|
||||||
|
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||||
|
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||||
|
},
|
||||||
|
...operationNames.map((operationName) => ({
|
||||||
|
label: operationName,
|
||||||
|
value: operationName,
|
||||||
|
})),
|
||||||
|
] satisfies RadioDropdownItem<string>[]}
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||||
|
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||||
|
<span className="text-text-subtle italic">Not specified</span>
|
||||||
|
) : (
|
||||||
|
currentBody.operationName ?? operationNames[0]
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</RadioDropdown>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
<div key="introspection" className="opacity-100!">
|
<div key="introspection" className="opacity-100!">
|
||||||
{schema === undefined ? null /* Initializing */ : (
|
{schema === undefined ? null /* Initializing */ : (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -85,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
...((schema != null
|
...((schema != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: "Clear Schema",
|
||||||
onSelect: clear,
|
onSelect: clear,
|
||||||
color: "danger",
|
color: "danger",
|
||||||
leftSlot: <Icon icon="trash" />,
|
leftSlot: <Icon icon="trash" />,
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
|
||||||
]
|
]
|
||||||
: []) satisfies DropdownItem[]),
|
: []) satisfies DropdownItem[]),
|
||||||
|
{
|
||||||
|
// Labels the source actions below it, so the menu says where the
|
||||||
|
// schema came from without spending a row on it.
|
||||||
|
type: "separator",
|
||||||
|
hidden: schema == null && filePath == null,
|
||||||
|
label:
|
||||||
|
fileName == null || filePath == null ? undefined : (
|
||||||
|
// Middle truncation: the head shrinks and ellipsizes while the
|
||||||
|
// tail is pinned, so the extension always survives. Full path
|
||||||
|
// on hover.
|
||||||
|
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
|
||||||
|
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
|
||||||
|
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
action:
|
||||||
|
filePath == null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
icon: "folder_symlink",
|
||||||
|
title: revealInFinderText,
|
||||||
|
onClick: () => platform.revealItemInDir(filePath),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
hidden: !error,
|
hidden: !error,
|
||||||
label: (
|
label: (
|
||||||
@@ -135,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
type: "content",
|
type: "content",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hidden: schema == null,
|
// One refresh action for either source: re-read the file, or
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
// re-introspect the server.
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
label: "Reload Schema",
|
||||||
onSelect: () => {
|
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
|
||||||
...v,
|
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Introspect Schema",
|
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
keepOpenOnSelect: true,
|
keepOpenOnSelect: true,
|
||||||
onSelect: refetch,
|
// Failures surface through the hook's error state either way.
|
||||||
|
onSelect: async () => {
|
||||||
|
if (filePath != null) await reloadFromFile();
|
||||||
|
else await refetch();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator", label: "Setting" },
|
|
||||||
{
|
{
|
||||||
label: "Automatic Introspection",
|
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
||||||
|
leftSlot: <Icon icon="import" />,
|
||||||
|
onSelect: handleLoadFromFile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hidden: filePath == null,
|
||||||
|
label: "Stop Using File",
|
||||||
|
leftSlot: <Icon icon="x" />,
|
||||||
|
onSelect: removeSchemaFile,
|
||||||
|
},
|
||||||
|
{ type: "separator", label: "Settings" },
|
||||||
|
{
|
||||||
|
// Governs both sources: re-introspecting the server, and
|
||||||
|
// re-reading the file when the request is opened.
|
||||||
|
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
|
||||||
keepOpenOnSelect: true,
|
keepOpenOnSelect: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setAutoIntrospectDisabled({
|
setAutoIntrospectDisabled({
|
||||||
@@ -185,16 +330,46 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
// Sits after the schema control it depends on. Always rendered, disabled
|
||||||
|
// without a schema, so the row never changes shape.
|
||||||
|
<div key="documentation" className="opacity-100!">
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="border"
|
||||||
|
icon="book_open_text"
|
||||||
|
disabled={schema == null}
|
||||||
|
title={
|
||||||
|
schema == null
|
||||||
|
? "Documentation unavailable without a schema"
|
||||||
|
: isDocOpen
|
||||||
|
? "Hide Documentation"
|
||||||
|
: "Show Documentation"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setGraphqlDocStateAtomValue((v) => ({
|
||||||
|
...v,
|
||||||
|
[request.id]: isDocOpen ? undefined : null,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
schema,
|
schema,
|
||||||
clear,
|
clear,
|
||||||
error,
|
error,
|
||||||
|
currentBody.operationName,
|
||||||
|
handleChangeOperationName,
|
||||||
isDocOpen,
|
isDocOpen,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
operationNames,
|
||||||
refetch,
|
refetch,
|
||||||
|
handleLoadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
autoIntrospectDisabled,
|
autoIntrospectDisabled,
|
||||||
baseRequest.id,
|
baseRequest.id,
|
||||||
setGraphqlDocStateAtomValue,
|
setGraphqlDocStateAtomValue,
|
||||||
@@ -237,10 +412,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParseJson(text: string, fallback: unknown) {
|
function buildGraphQLBody(body: {
|
||||||
try {
|
query: string;
|
||||||
return JSON.parse(text);
|
variables: string | undefined;
|
||||||
} catch {
|
operationName?: string;
|
||||||
return fallback;
|
}) {
|
||||||
|
const result: {
|
||||||
|
query: string;
|
||||||
|
variables: string | undefined;
|
||||||
|
operationName?: string;
|
||||||
|
} = {
|
||||||
|
query: body.query,
|
||||||
|
variables: body.variables || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof body.operationName === "string") {
|
||||||
|
result.operationName = body.operationName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ bodyPath, data }: Props) {
|
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyUrl) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data) {
|
} 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
|
||||||
const url = URL.createObjectURL(blob);
|
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||||
setSrc(url);
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||||
return () => URL.revokeObjectURL(url);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
setSrc(objectUrl);
|
||||||
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <audio className="w-full" controls src={src} />;
|
return <audio className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { CsvViewerInner } from "./CsvViewer";
|
||||||
|
|
||||||
|
vi.mock("@yaakapp-internal/ui", () => ({
|
||||||
|
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||||
|
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||||
|
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||||
|
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||||
|
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||||
|
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CsvViewer", () => {
|
||||||
|
test("renders columns that extend beyond the first row", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<CsvViewerInner
|
||||||
|
text={[
|
||||||
|
"startDate,2026-02-03T00:00-03:00",
|
||||||
|
"endDate,2026-02-03T23:59:59-03:00",
|
||||||
|
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||||
|
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||||
|
].join("\n")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("ID de usuario");
|
||||||
|
expect(markup).toContain("42041");
|
||||||
|
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
|||||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||||
const parsed = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
if (text == null) return null;
|
if (text == null) return null;
|
||||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
if (parsed === null) return null;
|
if (parsed === null) return null;
|
||||||
|
|
||||||
|
const header = parsed.data[0] ?? [];
|
||||||
|
const rows = parsed.data.slice(1);
|
||||||
|
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||||
|
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<Table className={classNames(className, "text-sm")}>
|
<Table className={classNames(className, "text-sm")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{parsed.meta.fields?.map((field) => (
|
{columnIndexes.map((columnIndex) => (
|
||||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{parsed.data.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
{parsed.meta.fields?.map((key) => (
|
{row.map((cell, columnIndex) => (
|
||||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
|
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
summary.data.fragmentCount === 0 &&
|
summary.data.fragmentCount === 0 &&
|
||||||
!summary.isFetching &&
|
!summary.isFetching &&
|
||||||
summary.error == null;
|
summary.error == null;
|
||||||
|
|
||||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
||||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
||||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
||||||
@@ -218,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
<SseSummaryFooter
|
<SseSummaryFooter
|
||||||
style={style}
|
style={style}
|
||||||
error={summary.error ? String(summary.error) : null}
|
error={summary.error ? String(summary.error) : null}
|
||||||
isLoading={summary.isLoading}
|
isLoading={summary.isLoading && summary.data == null}
|
||||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
onRenderMarkdownChange={renderMarkdownSetting.set}
|
||||||
renderMarkdown={renderMarkdown}
|
renderMarkdown={renderMarkdown}
|
||||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||||
|
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
||||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
import { languageFromContentType } from "../../lib/contentType";
|
||||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||||
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
|
const filter = useResponseFilter({
|
||||||
|
stateKey: `response.body.${response.requestId}`,
|
||||||
|
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
||||||
|
runFilter: useCallback(
|
||||||
|
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||||
|
[queryClient, response],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
||||||
const saveResponse = useSaveResponse(response);
|
const saveResponse = useSaveResponse(response);
|
||||||
const copyResponse = useCopyHttpResponse(response);
|
const copyResponse = useCopyHttpResponse(response);
|
||||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||||
|
|
||||||
const filterCallback = useMemo(
|
|
||||||
() => (filter: string) => {
|
|
||||||
setCurrentFilter(filter);
|
|
||||||
return {
|
|
||||||
data: filteredBody.data,
|
|
||||||
isPending: filteredBody.isPending,
|
|
||||||
error: !!filteredBody.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[filteredBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextViewer
|
<TextViewer
|
||||||
text={text}
|
text={text}
|
||||||
language={language}
|
language={language}
|
||||||
stateKey={`response.body.${response.id}`}
|
stateKey={`response.body.${response.id}`}
|
||||||
filterStateKey={`response.body.${response.requestId}`}
|
|
||||||
pretty={pretty}
|
pretty={pretty}
|
||||||
className={className}
|
className={className}
|
||||||
footerActions={[
|
footerActions={[
|
||||||
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
className="border !border-border-subtle"
|
className="border !border-border-subtle"
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
onFilter={filterCallback}
|
filter={filter}
|
||||||
|
filterResult={{
|
||||||
|
data: filteredBody.data,
|
||||||
|
isPending: filteredBody.isPending,
|
||||||
|
error: !!filteredBody.error,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Props = { className?: string } & (
|
type Props = { className?: string; mimeType?: string } & (
|
||||||
| {
|
| {
|
||||||
bodyPath: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ImageViewer({ className, ...props }: Props) {
|
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath != null) {
|
if (bodyUrl != null) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data != null) {
|
} 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);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(objectUrl);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import "react-pdf/dist/Page/TextLayer.css";
|
import "react-pdf/dist/Page/TextLayer.css";
|
||||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import "./PdfViewer.css";
|
import "./PdfViewer.css";
|
||||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
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 { Document, Page } from "react-pdf";
|
||||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
@@ -18,7 +17,8 @@ fireAndForget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,29 +27,35 @@ const options = {
|
|||||||
standardFontDataUrl: "/standard_fonts/",
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PdfViewer({ bodyPath, data }: Props) {
|
export function PdfViewer({ bodyUrl, data }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const [src, setSrc] = useState<string | { data: Uint8Array }>();
|
|
||||||
|
|
||||||
const { width: containerWidth } = useContainerSize(containerRef);
|
const { width: containerWidth } = useContainerSize(containerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||||
if (bodyPath) {
|
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||||
setSrc(convertFileSrc(bodyPath));
|
const src = useMemo(() => {
|
||||||
} else if (data) {
|
if (bodyUrl) {
|
||||||
|
return bodyUrl;
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
// Create a copy to avoid "Buffer is already detached" errors
|
// Create a copy to avoid "Buffer is already detached" errors
|
||||||
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
||||||
const dataCopy = new Uint8Array(data);
|
return { data: new Uint8Array(data) };
|
||||||
setSrc({ data: dataCopy });
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
return undefined;
|
||||||
|
}, [bodyUrl, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
|
||||||
|
if (src == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||||
<Document
|
<Document
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
||||||
|
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recentFilters: RecentFilter[];
|
||||||
|
activeFilter: string | null;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
onTogglePin: (value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentFiltersDropdown({
|
||||||
|
recentFilters,
|
||||||
|
activeFilter,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onTogglePin,
|
||||||
|
onClear,
|
||||||
|
}: Props) {
|
||||||
|
const pinned = recentFilters.filter((f) => f.pinned);
|
||||||
|
const unpinned = recentFilters.filter((f) => !f.pinned);
|
||||||
|
|
||||||
|
const toItem = (filter: RecentFilter): DropdownItem => ({
|
||||||
|
label: (
|
||||||
|
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
||||||
|
{filter.value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
||||||
|
onSelect: () => onSelect(filter.value),
|
||||||
|
submenuTrigger: "button",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: filter.pinned ? "Unpin" : "Pin",
|
||||||
|
icon: filter.pinned ? "unpin" : "pin",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onTogglePin(filter.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remove",
|
||||||
|
icon: "trash",
|
||||||
|
color: "danger",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onRemove(filter.value),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: DropdownItem[] = [];
|
||||||
|
|
||||||
|
if (recentFilters.length === 0) {
|
||||||
|
items.push({
|
||||||
|
type: "content",
|
||||||
|
label: (
|
||||||
|
<span className="block px-4 py-1 text-sm text-text-subtle">
|
||||||
|
Filters you use are remembered here
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unpinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentFilters.length > 0) {
|
||||||
|
items.push(
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Clear All",
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
color: "danger",
|
||||||
|
onSelect: onClear,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="filter"
|
||||||
|
title="Recent filters"
|
||||||
|
iconColor="secondary"
|
||||||
|
className="w-8 ml-0.5 mr-1 h-auto!"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useCallback, useMemo } from "react";
|
import { Children, useCallback, useMemo } from "react";
|
||||||
import { createGlobalState } from "react-use";
|
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
|
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||||
|
import { Button } from "../core/Button";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
|
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
const extraExtensions = [hyperlink];
|
||||||
|
|
||||||
@@ -16,56 +17,44 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
filterStateKey?: string | null;
|
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
footerActions?: ReactNode;
|
footerActions?: ReactNode;
|
||||||
onFilter?: (filter: string) => {
|
filter?: ResponseFilterApi;
|
||||||
|
filterResult?: {
|
||||||
data: string | null | undefined;
|
data: string | null | undefined;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
export function TextViewer({
|
export function TextViewer({
|
||||||
language,
|
language,
|
||||||
text,
|
text,
|
||||||
stateKey,
|
stateKey,
|
||||||
filterStateKey,
|
|
||||||
pretty,
|
pretty,
|
||||||
className,
|
className,
|
||||||
footerActions,
|
footerActions,
|
||||||
onFilter,
|
filter,
|
||||||
|
filterResult,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const filterKey = filterStateKey ?? stateKey;
|
const canFilter =
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||||
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
const isSearching = filter?.isSearching ?? false;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const appliedFilter = filter?.appliedFilter ?? null;
|
||||||
const setFilterText = useCallback(
|
const resultError = filterResult?.error ?? false;
|
||||||
(v: string | null) => {
|
|
||||||
if (!filterKey) return;
|
|
||||||
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
|
||||||
},
|
|
||||||
[filterKey, setFilterTextMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
const handleFilterKeyDown = useCallback(
|
||||||
const filteredResponse =
|
(e: KeyboardEvent) => {
|
||||||
onFilter && debouncedFilterText
|
if (filter == null) return;
|
||||||
? onFilter(debouncedFilterText)
|
if (e.key === "Escape") {
|
||||||
: { data: null, isPending: false, error: false };
|
filter.toggleSearch();
|
||||||
|
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||||
const toggleSearch = useCallback(() => {
|
filter.applyFilter(filter.filterText);
|
||||||
if (isSearching) {
|
|
||||||
setFilterText(null);
|
|
||||||
} else {
|
|
||||||
setFilterText("");
|
|
||||||
}
|
}
|
||||||
}, [isSearching, setFilterText]);
|
},
|
||||||
|
[filter],
|
||||||
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
|
);
|
||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
const actions = useMemo<ReactNode[]>(() => {
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||||
@@ -76,8 +65,8 @@ export function TextViewer({
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<div key="input" className="w-full opacity-100!">
|
<div key="input" className="w-full opacity-100!">
|
||||||
<Input
|
<Input
|
||||||
key={filterKey ?? "filter"}
|
key={filter.stateKey ?? "filter"}
|
||||||
validate={!filteredResponse.error}
|
validate={!resultError}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
containerClassName="bg-surface"
|
containerClassName="bg-surface"
|
||||||
@@ -85,39 +74,62 @@ export function TextViewer({
|
|||||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||||
label="Filter expression"
|
label="Filter expression"
|
||||||
name="filter"
|
name="filter"
|
||||||
defaultValue={filterText}
|
defaultValue={filter.filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
forceUpdateKey={filter.filterUpdateKey}
|
||||||
onChange={setFilterText}
|
onKeyDown={handleFilterKeyDown}
|
||||||
stateKey={filterKey ? `filter.${filterKey}` : null}
|
onChange={filter.setFilterText}
|
||||||
|
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
||||||
|
leftSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<RecentFiltersDropdown
|
||||||
|
recentFilters={filter.recentFilters}
|
||||||
|
activeFilter={filter.appliedFilter}
|
||||||
|
onSelect={filter.replaceFilter}
|
||||||
|
onRemove={filter.removeRecentFilter}
|
||||||
|
onTogglePin={filter.togglePinRecentFilter}
|
||||||
|
onClear={filter.clearRecentFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="x"
|
||||||
|
title="Close filter"
|
||||||
|
iconColor="secondary"
|
||||||
|
onClick={filter.toggleSearch}
|
||||||
|
className="w-8 mr-0.5 h-auto!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<IconButton
|
<IconButton
|
||||||
key="icon"
|
key="icon"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={filteredResponse.isPending}
|
isLoading={filterResult?.isPending ?? false}
|
||||||
icon={isSearching ? "x" : "filter"}
|
icon="filter"
|
||||||
title={isSearching ? "Close filter" : "Filter response"}
|
title="Filter response"
|
||||||
onClick={toggleSearch}
|
onClick={filter.toggleSearch}
|
||||||
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
|
className="border border-border-subtle!"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
footerActions,
|
footerActions,
|
||||||
filterKey,
|
filter,
|
||||||
filterText,
|
filterResult?.isPending,
|
||||||
filteredResponse.error,
|
resultError,
|
||||||
filteredResponse.isPending,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
setFilterText,
|
handleFilterKeyDown,
|
||||||
toggleSearch,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||||
@@ -126,11 +138,11 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (isSearching && filterText?.length > 0) {
|
if (appliedFilter) {
|
||||||
if (filteredResponse.error) {
|
if (resultError) {
|
||||||
body = "";
|
body = "";
|
||||||
} else {
|
} else {
|
||||||
body = filteredResponse.data != null ? filteredResponse.data : "";
|
body = filterResult?.data != null ? filterResult.data : "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body = formattedBody;
|
body = formattedBody;
|
||||||
@@ -143,6 +155,16 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||||
|
{appliedFilter && filter != null ? (
|
||||||
|
<AppliedFilterBar
|
||||||
|
filter={appliedFilter}
|
||||||
|
error={resultError}
|
||||||
|
onClear={() => filter.replaceFilter("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<Editor
|
<Editor
|
||||||
readOnly
|
readOnly
|
||||||
className={className}
|
className={className}
|
||||||
@@ -152,6 +174,42 @@ export function TextViewer({
|
|||||||
extraExtensions={extraExtensions}
|
extraExtensions={extraExtensions}
|
||||||
stateKey={stateKey}
|
stateKey={stateKey}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||||
|
* once it holds an edited expression that hasn't been applied yet.
|
||||||
|
*/
|
||||||
|
function AppliedFilterBar({
|
||||||
|
filter,
|
||||||
|
error,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
filter: string;
|
||||||
|
error: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||||
|
<HStack space={2} className="min-w-0">
|
||||||
|
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||||
|
<span className="truncate min-w-0" title={filter}>
|
||||||
|
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||||
|
{error && " (invalid expression)"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color={error ? "danger" : "info"}
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ bodyPath, data }: Props) {
|
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyUrl) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data) {
|
} 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 url = URL.createObjectURL(blob);
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||||
setSrc(url);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
return () => URL.revokeObjectURL(url);
|
setSrc(objectUrl);
|
||||||
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <video className="w-full" controls src={src} />;
|
return <video className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Listen for settings changes, the re-compute theme
|
// Listen for settings changes, the re-compute theme
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||||
import { fireAndForget } from "./lib/fireAndForget";
|
import { fireAndForget } from "./lib/fireAndForget";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
|
|||||||
document.documentElement.style.fontSize = `${fontSize}px`;
|
document.documentElement.style.fontSize = `${fontSize}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen<ModelPayload>("model_write", async (event) => {
|
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
if (event.payload.change.type !== "upsert") return;
|
for (const payload of payloads) {
|
||||||
if (event.payload.model.model !== "settings") return;
|
if (payload.change.type !== "upsert") continue;
|
||||||
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
|
if (payload.model.model !== "settings") continue;
|
||||||
}).catch(console.error);
|
setFontSizeOnDocument(payload.model.interfaceFontSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
|
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Listen for settings changes, the re-compute theme
|
// Listen for settings changes, the re-compute theme
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
|
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
|
||||||
import { fireAndForget } from "./lib/fireAndForget";
|
import { fireAndForget } from "./lib/fireAndForget";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
listen<ModelPayload>("model_write", async (event) => {
|
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
if (event.payload.change.type !== "upsert") return;
|
for (const payload of payloads) {
|
||||||
if (event.payload.model.model !== "settings") return;
|
if (payload.change.type !== "upsert") continue;
|
||||||
setFonts(event.payload.model);
|
if (payload.model.model !== "settings") continue;
|
||||||
}).catch(console.error);
|
setFonts(payload.model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fireAndForget(getSettings().then((settings) => setFonts(settings)));
|
fireAndForget(getSettings().then((settings) => setFonts(settings)));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
websocketRequestsAtom,
|
websocketRequestsAtom,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
|
import { selectAtom } from "jotai/utils";
|
||||||
|
|
||||||
export const allRequestsAtom = atom((get) => [
|
export const allRequestsAtom = atom((get) => [
|
||||||
...get(httpRequestsAtom),
|
...get(httpRequestsAtom),
|
||||||
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
|
|||||||
export function useAllRequests() {
|
export function useAllRequests() {
|
||||||
return useAtomValue(allRequestsAtom);
|
return useAtomValue(allRequestsAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stringArrayEqual = (a: string[], b: string[]) =>
|
||||||
|
a.length === b.length && a.every((v, i) => v === b[i]);
|
||||||
|
|
||||||
|
// Identity-stable derivations so subscribers don't recompute or re-render when
|
||||||
|
// unrelated request fields change (eg. every debounced edit of a request)
|
||||||
|
export const allRequestIdsAtom = selectAtom(
|
||||||
|
allRequestsAtom,
|
||||||
|
(requests) => requests.map((r) => r.id),
|
||||||
|
stringArrayEqual,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const allRequestUrlsAtom = selectAtom(
|
||||||
|
allRequestsAtom,
|
||||||
|
(requests) => {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
for (const r of requests) {
|
||||||
|
if (r.url) urls.add(r.url);
|
||||||
|
}
|
||||||
|
return Array.from(urls);
|
||||||
|
},
|
||||||
|
stringArrayEqual,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { event } from "@tauri-apps/api";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useCancelHttpResponse(id: string | null) {
|
export function useCancelHttpResponse(id: string | null) {
|
||||||
return useFastMutation<void>({
|
return useFastMutation<void>({
|
||||||
mutationKey: ["cancel_http_response", id],
|
mutationKey: ["cancel_http_response", id],
|
||||||
mutationFn: () => event.emit(`cancel_http_response_${id}`),
|
mutationFn: () => platform.emit(`cancel_http_response_${id}`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { InlineCode } from "@yaakapp-internal/ui";
|
|||||||
import { showAlert } from "../lib/alert";
|
import { showAlert } from "../lib/alert";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export function useCheckForUpdates() {
|
export function useCheckForUpdates() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ["check_for_updates"],
|
mutationKey: ["check_for_updates"],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const hasUpdate: boolean = await minPromiseMillis(invokeCmd("cmd_check_for_updates"), 500);
|
const hasUpdate: boolean = await minPromiseMillis(rpc("cmd_check_for_updates"), 500);
|
||||||
if (!hasUpdate) {
|
if (!hasUpdate) {
|
||||||
showAlert({
|
showAlert({
|
||||||
id: "no-updates",
|
id: "no-updates",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useDeleteGrpcConnections(requestId?: string) {
|
export function useDeleteGrpcConnections(requestId?: string) {
|
||||||
@@ -6,7 +6,7 @@ export function useDeleteGrpcConnections(requestId?: string) {
|
|||||||
mutationKey: ["delete_grpc_connections", requestId],
|
mutationKey: ["delete_grpc_connections", requestId],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (requestId === undefined) return;
|
if (requestId === undefined) return;
|
||||||
await invokeCmd("cmd_delete_all_grpc_connections", { requestId });
|
await rpc("cmd_delete_all_grpc_connections", { requestId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useDeleteHttpResponses(requestId?: string) {
|
export function useDeleteHttpResponses(requestId?: string) {
|
||||||
@@ -6,7 +6,7 @@ export function useDeleteHttpResponses(requestId?: string) {
|
|||||||
mutationKey: ["delete_http_responses", requestId],
|
mutationKey: ["delete_http_responses", requestId],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (requestId === undefined) return;
|
if (requestId === undefined) return;
|
||||||
await invokeCmd("cmd_delete_all_http_responses", { requestId });
|
await rpc("cmd_delete_all_http_responses", { requestId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user