Compare commits

...

61 Commits

Author SHA1 Message Date
Gregory Schier d15e460bd3 Format plugin code 2026-07-02 21:18:04 -07:00
Gregory Schier 304827b6b4 Fix Linux CEF cache path 2026-07-02 21:09:21 -07:00
Gregory Schier ed835fa6df Support CEF runtime in window commands 2026-07-02 21:05:38 -07:00
Gregory Schier fe0ba3caf9 Preserve dev runner args separator 2026-07-02 20:50:27 -07:00
Gregory Schier 752b0463d0 Add CEF runtime to Linux builds 2026-07-02 19:48:28 -07:00
Gregory Schier 5db2008fae Add Yaak changelog skill 2026-07-02 10:17:53 -07:00
Gregory Schier 95ac3e310a Improve response history menu (#492) 2026-07-02 10:04:57 -07:00
Gregory Schier 9b524e3dc7 Upgrade Tailwind to v4 (#491) 2026-07-02 09:53:22 -07:00
Gregory Schier bdf78254b5 Disable automatic Flathub workflow 2026-07-01 13:51:00 -07:00
Gregory Schier c5545c8781 Document release note contributor attribution 2026-07-01 13:39:05 -07:00
Simon Johansson 5004c395de fix: Resolve : ambiguity in URL path placeholders (#465)
Co-authored-by: Simon Johansson <simon.johansson@infor.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 13:23:27 -07:00
Gregory Schier ea3587f28d Fix commercial use banner snooze 2026-07-01 12:44:44 -07:00
baofeidyz 24e578db5f Add SSE response summary helpers (#466)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 12:33:03 -07:00
Gregory Schier 12562aa076 Skip older PRs in contribution policy automation 2026-07-01 11:53:38 -07:00
Joris van Eijden 5a74a989b5 Support string-based url.path in Postman importer. (#490) 2026-07-01 11:47:53 -07:00
Gregory Schier a6558329e2 Run contribution policy on PR updates 2026-07-01 11:01:15 -07:00
Gregory Schier 54a931d94d Require screenshots confirmation in PR template 2026-06-30 15:24:38 -07:00
Gregory Schier 5229534d8f Clarify blocked contribution labels 2026-06-30 15:16:28 -07:00
Gregory Schier 78b3996f47 Limit community PR policy to bug fixes 2026-06-30 15:05:31 -07:00
Gregory Schier d9f7bf7fdd Remove PR body note from policy comments 2026-06-30 15:00:54 -07:00
Gregory Schier 45c410dd4c Clarify contribution policy blocker wording 2026-06-30 14:57:29 -07:00
Gregory Schier 80e56281b2 Include PR template in policy comment 2026-06-30 14:45:44 -07:00
Gregory Schier 125eae052b Shorten contribution policy inputs 2026-06-30 14:40:15 -07:00
Gregory Schier 6f52bb7533 Clarify explicit contribution permission 2026-06-30 14:36:46 -07:00
Gregory Schier 8724260eb4 Allow targeted contribution policy runs 2026-06-30 14:25:41 -07:00
Gregory Schier f32e9f7704 Refine contribution policy labels 2026-06-30 14:20:13 -07:00
Gregory Schier 83c8371e94 Support contribution policy label overrides 2026-06-30 14:15:37 -07:00
Gregory Schier 5f14d90ccd Preview contribution policy comments 2026-06-30 13:54:45 -07:00
Gregory Schier ff0d8c03b0 Improve contribution policy summary 2026-06-30 13:41:02 -07:00
Gregory Schier 1dd7e728ff Add contribution policy workflow 2026-06-30 13:34:11 -07:00
Gregory Schier 3a349bccfe Fix wording 2026-06-30 10:28:39 -07:00
Gregory Schier 13a667a9b1 Add commercial use nudge banners (#478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Nguyễn Huỳnh Anh Khoa <113995598+anhkhoakz@users.noreply.github.com>
Co-authored-by: startsevdenis <mail@startsevds.ru>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:23:13 -07:00
Gregory Schier 420c6e2c4a Tweak message size placeholder and notifications 2026-06-30 09:44:11 -07:00
Gregory Schier bbdfbcb9ca Add configurable gRPC and WebSocket message size limit (#487) 2026-06-30 09:14:41 -07:00
dependabot[bot] d1e6f8fb33 Bump js-cookie and react-use (#488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:33:14 -07:00
dependabot[bot] 930a816f42 Bump ws from 8.20.1 to 8.21.0 (#480)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:54 -07:00
dependabot[bot] ec0143aa93 Bump hono from 4.12.18 to 4.12.25 (#479)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:47 -07:00
dependabot[bot] 3cc54dea22 Bump vite-plus from 0.1.20 to 0.1.24 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:41 -07:00
dependabot[bot] a8fb144c09 Bump esbuild and tsx (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:33 -07:00
dependabot[bot] 6813fa8bf2 Bump shell-quote from 1.8.3 to 1.8.4 (#471)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:25 -07:00
dependabot[bot] cf7de26a2e Bump tar from 0.4.45 to 0.4.46 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:16 -07:00
dependabot[bot] 8676272657 Bump qs from 6.14.1 to 6.15.2 (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:10 -07:00
startsevdenis c3aecfdc0c fix: increase tonic gRPC max_decoding_message_size to 64MB 2026-06-29 16:06:02 -07:00
Gregory Schier 09adcda2d9 Add plugin metadata generation (#485) 2026-06-29 12:31:49 -07:00
Gregory Schier 18b983bfe5 Add CLI import and export commands (#484) 2026-06-29 11:43:20 -07:00
Gregory Schier 9ffd8d4810 Flush model writes before sending HTTP requests 2026-06-29 10:25:15 -07:00
Gregory Schier 55d0066efd Fix spell correction prompt showing (#483) 2026-06-29 08:54:01 -07:00
Nguyễn Huỳnh Anh Khoa 1de0a5942c fix(manager): remove stale plugins with missing directories (#481) 2026-06-26 22:33:06 -07:00
Gregory Schier fd0ca6d455 Fix bulk env var parsing (#482) 2026-06-26 21:58:38 -07:00
Gregory Schier 84b89e2708 update theme generation logic 2026-06-21 10:37:43 -07:00
Gregory Schier 7db3e9b879 Fix filter field value highlighting 2026-06-20 00:31:42 -07:00
Gregory Schier 8109a28967 Improve sidebar filter suggestions (#477) 2026-06-20 00:10:05 -07:00
Gregory Schier 3de9a1edd4 Persist response filter per request 2026-06-11 09:09:12 -07:00
Gregory Schier 1b28dfd9d1 Actually fix overflowing text when Input has right slot items 2026-06-03 12:44:33 -07:00
Saverio Cannone 9f51c61447 Fix: long model names overflowing in delete dialog (#468) 2026-05-26 23:16:50 -07:00
zPush b17ccbeebe Fix: Secret input field texts were bleeding under obscure toggle button (#461)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-05-21 09:36:20 -07:00
Jeroen van der Merwe 463cc6f5a3 feat: Extract authentication when using the cURL importer (#423) 2026-05-21 09:00:22 -07:00
dependabot[bot] 1307ea4e67 Bump ws from 8.19.0 to 8.20.1 (#464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 07:58:42 -07:00
dependabot[bot] 710b8e34ac Bump postcss from 8.5.6 to 8.5.14 (#449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 07:26:54 -07:00
Stijn Brouwers f251772a4a feat(cookies): Allow manually adding cookies to the cookiejar (#457)
Co-authored-by: Stijn BROUWERS <stijn.brouwers@ext.ec.europa.eu>
2026-05-20 07:43:03 -07:00
Gregory Schier fa40ceaa31 Add cookie editing and inherited request settings (#463) 2026-05-18 08:59:49 -07:00
259 changed files with 13334 additions and 3717 deletions
@@ -19,10 +19,12 @@ Generate formatted markdown release notes for a Yaak tag.
- `gh pr view <PR_NUMBER> --json number,title,body,author,url` - `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details: 5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`) - Feedback URLs (`feedback.yaak.app`)
- Contributor GitHub handles from `author.login`
- Plugin install links or other notable context - Plugin install links or other notable context
6. Format notes using Yaak style: 6. Format notes using Yaak style:
- Changelog badge at top - Changelog badge at top
- Bulleted items with PR links where available - Bulleted items with PR links where available
- Contributor handles for external PRs
- Feedback links where available - Feedback links where available
- Full changelog compare link at bottom - Full changelog compare link at bottom
@@ -31,6 +33,7 @@ Generate formatted markdown release notes for a Yaak tag.
- Wrap final notes in a markdown code fence. - Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence. - Keep a blank line before and after the code fence.
- Output the markdown code block last. - Output the markdown code block last.
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
- Do not append `by @gschier` for PRs authored by `@gschier`. - Do not append `by @gschier` for PRs authored by `@gschier`.
- 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. - 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.
+104
View File
@@ -0,0 +1,104 @@
---
name: yaak-changelog
description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
---
# Yaak Changelog
Use this skill to create Yaak changelogs in the correct place:
- Beta or draft prerelease changelogs live only on the GitHub release.
- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
## Workflow
1. Identify the target release.
- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
2. Parse release bullets.
- Treat each release-note bullet as one changelog entry.
- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
- Preserve the entry wording closely. Remove wrapping quotes from titles.
- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
3. For beta or draft prerelease changelogs, update the GitHub release.
- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
- Prefer concise bullets with PR links and feedback links when available.
- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
- Stop after verifying the GitHub release body. The website checks below do not apply.
4. For stable website changelogs, create or edit the release directory.
- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
- For a new release, use today's date for `YYYYMMDD`.
- For an existing release, keep the original directory date.
- Do not create changelog directories for beta releases.
5. Write `_release.yaml`.
- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
- Keep minor items as quick entries without `content`.
- Use `content` only when an entry needs its own markdown section.
```yaml
title: "What's New in 2026.1.0"
summary: "Brief overview of the most important additions and fixes"
draft: true
entries:
- title: "Request debugging"
category: feature
pr: "https://github.com/mountain-loop/yaak/pull/123"
feedback: "https://feedback.yaak.app/p/request-debugging"
content: "request-debugging.md"
- title: "Fix broken cookie clearing"
category: fix
pr: "https://github.com/mountain-loop/yaak/pull/124"
```
6. Expand major entries.
- Expand 3 to 6 major items when enough context exists.
- Create slugified markdown files and reference them with `content`.
- Read the related PR before writing expanded content.
- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
7. Handle images.
- Reuse screenshots from PRs when they exist.
- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
- If no real image is available, use a placeholder with real alt text and a caption.
8. Write `_intro.md`.
- Add a short overview paragraph at the top of the release.
- Focus on the major themes across the release instead of repeating every bullet.
9. Follow Yaak writing style.
- Be direct and factual. Avoid hype.
- State what changed and how to use it.
- Keep paragraphs short.
- Use backticks for code symbols, settings, and literal values.
- Use bold sparingly for the most important phrase in a section.
## File Rules
- Beta releases must not create or edit files in `src/content/changelog/`.
- Main files are `_release.yaml` and optional `_intro.md`.
- Expanded entry files are regular markdown files such as `request-debugging.md`.
- `entries[].content` must match an existing markdown filename in the same directory.
- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
## Checks
- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
- Ensure most entries include `pr` when the source release notes provide one.
- For stable releases, ensure every referenced `content` file exists.
- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
@@ -0,0 +1,7 @@
interface:
display_name: "Yaak Changelog"
short_description: "Generate Yaak changelog releases"
default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
policy:
allow_implicit_invocation: true
+4 -3
View File
@@ -4,13 +4,14 @@
## Submission ## Submission
- [ ] This PR is a bug fix or small-scope improvement. - [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below. - [ ] 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 when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement): Explicit permission feedback item (required if not a bug fix):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
@@ -0,0 +1,848 @@
const fs = require("node:fs");
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const LABELS = {
inScope: {
name: "contribution: in scope",
color: "0E8A16",
description: "Community PR appears to be in scope for maintainer review.",
},
outOfScope: {
name: "contribution: out of scope",
color: "B60205",
description: "Community PR does not match Yaak's contribution policy.",
},
explicitPermission: {
name: "contribution: explicit permission",
color: "5319E7",
description:
"Community PR links feedback where @gschier explicitly allowed the work.",
},
missingTemplate: {
name: "contribution: missing template",
color: "D93F0B",
description:
"Community PR is missing enough of the pull request template to review.",
},
policyUnmet: {
name: "contribution: policy unmet",
color: "B60205",
description:
"Community PR does not currently satisfy the contribution policy.",
},
needsScopeReview: {
name: "contribution: needs scope review",
color: "FBCA04",
description:
"Community PR may be broader than Yaak's bug-fix contribution policy.",
},
};
const MANAGED_LABEL_NAMES = [
...new Set(Object.values(LABELS).map((label) => label.name)),
];
const CHECKBOXES = {
bugFix: "This PR is a bug fix.",
explicitPermission:
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
"I added screenshots or recordings for UI changes when reasonable.",
};
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeBody(body) {
return (body || "").replace(/\r\n/g, "\n");
}
function stripComments(value) {
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
}
function getSection(body, heading) {
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
const match = pattern.exec(body);
if (match == null) {
return null;
}
const rest = body.slice(match.index + match[0].length);
const nextHeadingIndex = rest.search(/^##\s+/m);
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
}
function hasMeaningfulText(value) {
return stripComments(value || "").length > 0;
}
function normalizeCheckboxLabel(label) {
return label
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim();
}
function checkboxState(body, label) {
const expectedLabel = normalizeCheckboxLabel(label);
for (const line of body.split("\n")) {
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
if (match == null) {
continue;
}
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
return match[1].toLowerCase() === "x";
}
}
return null;
}
function findFeedbackUrl(body) {
return (
body.match(
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
)?.[0] ?? null
);
}
function getLabelNames(pr) {
return new Set((pr.labels || []).map((label) => label.name));
}
function analyzePullRequest(pr) {
const body = normalizeBody(pr.body);
const labelNames = getLabelNames(pr);
const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [
key,
checkboxState(body, label),
]),
);
const sectionCount = ["Summary", "Submission", "Related"].filter(
(heading) => getSection(body, heading) != null,
).length;
const checkboxCount = Object.values(states).filter(
(state) => state != null,
).length;
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
const blockers = [];
const totalChangedLines =
Number(pr.additions || 0) + Number(pr.deletions || 0);
const changedFiles = Number(pr.changed_files || 0);
const largeDiff =
changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (labelNames.has(LABELS.outOfScope.name)) {
return {
blockers: [
{
label: LABELS.outOfScope.name,
message: "Marked out of scope by maintainer label.",
},
],
changedFiles,
desiredLabels: [LABELS.outOfScope.name],
largeDiff,
status: "out_of_scope",
templateUsed,
totalChangedLines,
};
}
if (labelNames.has(LABELS.inScope.name)) {
return {
blockers: [],
changedFiles,
desiredLabels: [LABELS.inScope.name],
largeDiff,
status: "in_scope",
templateUsed,
totalChangedLines,
};
}
if (!templateUsed) {
blockers.push({
label: LABELS.missingTemplate.name,
message:
"Update the PR description with the repository pull request template.",
});
} else {
const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body);
const bugFix = states.bugFix === true;
const explicitPermission = states.explicitPermission === true;
if (!hasSummary) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Add a short summary describing the bug fix or permitted change.",
});
}
if (bugFix && explicitPermission) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
});
} else if (!bugFix && !explicitPermission) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
});
} else if (explicitPermission && feedbackUrl == null) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
});
}
if (states.readContributing !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
});
}
if (states.testedLocally !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that the change was tested locally.",
});
}
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.",
});
}
if (states.screenshotsAdded !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
});
}
}
const desiredLabels = new Set();
if (blockers.length === 0) {
desiredLabels.add(
largeDiff
? LABELS.needsScopeReview.name
: states.explicitPermission
? LABELS.explicitPermission.name
: LABELS.inScope.name,
);
} else if (
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
) {
desiredLabels.add(LABELS.missingTemplate.name);
} else {
desiredLabels.add(LABELS.policyUnmet.name);
}
return {
blockers,
changedFiles,
desiredLabels: [...desiredLabels],
largeDiff,
status: blockers.length === 0 ? "in_scope" : "blocked",
templateUsed,
totalChangedLines,
};
}
function buildBlockingComment(analysis) {
const lines = [
COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
"",
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
"",
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
];
if (!analysis.templateUsed) {
lines.push(
"",
"You can copy this template into the PR description and keep any existing context that is still useful.",
"",
"<details>",
"<summary>PR description template</summary>",
"",
"```md",
getPullRequestTemplate(),
"```",
"",
"</details>",
);
}
if (analysis.largeDiff) {
lines.push(
"",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
);
}
return lines.join("\n");
}
function getPullRequestTemplate() {
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
}
function buildInScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
"",
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
].join("\n");
}
function buildOutOfScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
"",
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
"",
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
].join("\n");
}
function buildPolicyComment(analysis) {
if (analysis.status === "out_of_scope") {
return buildOutOfScopeComment();
}
if (analysis.blockers.length > 0) {
return buildBlockingComment(analysis);
}
return buildInScopeComment();
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function truncateTitle(title) {
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
}
function escapeTableText(value) {
return escapeHtml(value).replace(/\n/g, "<br>");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
const comment =
analysis == null
? "None"
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
const summary = {
blocked: analysis?.blockers.length > 0,
comment,
details: "None",
labels:
analysis?.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "None",
number: pr.number,
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
status: "In scope",
title: escapeHtml(truncateTitle(pr.title)),
};
if (skipped) {
return {
...summary,
blocked: false,
comment: "None",
details: escapeHtml(skipReason),
labels: "None",
status: "Skipped",
};
}
if (summary.blocked) {
return {
...summary,
comment: escapeTableText(summary.comment),
details: escapeHtml(
analysis.blockers.map((blocker) => blocker.message).join("; "),
),
labels: escapeHtml(summary.labels),
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
};
}
return {
...summary,
comment: escapeTableText(summary.comment),
labels: escapeHtml(summary.labels),
};
}
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
}
async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true;
}
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
return true;
}
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: pr.user.login,
});
return MAINTAINER_PERMISSIONS.has(response.data.permission);
} catch (error) {
if (error.status === 404) {
return false;
}
throw error;
}
}
async function ensureManagedLabels({ github, owner, repo }) {
for (const label of Object.values(LABELS)) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label.name,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label.name,
color: label.color,
description: label.description,
});
}
}
}
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
const desired = new Set(desiredLabels);
await ensureManagedLabels({ github, owner, repo });
for (const labelName of MANAGED_LABEL_NAMES) {
if (desired.has(labelName)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (desired.size > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: [...desired],
});
}
}
async function findPolicyComment({ github, owner, repo, issueNumber }) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return comments.find(
(comment) =>
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
);
}
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return;
}
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
}
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existingComment.id,
});
}
async function requestMaintainerReview({ github, owner, repo, pr }) {
if (pr.user.login === REVIEWER_LOGIN) {
return;
}
try {
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pr.number,
reviewers: [REVIEWER_LOGIN],
});
} catch (error) {
if (error.status === 422) {
return;
}
throw error;
}
}
async function checkPullRequest({
github,
core,
owner,
repo,
pullNumber,
dryRun,
skipCreatedBefore,
}) {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const pr = response.data;
const issueNumber = pr.number;
if (
skipCreatedBefore != null &&
wasCreatedBefore(pr.created_at, skipCreatedBefore)
) {
core.notice(
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
}),
skipped: true,
};
}
if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: "draft",
}),
skipped: true,
};
}
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
core.notice(
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
);
if (!dryRun) {
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
await deletePolicyComment({ github, owner, repo, issueNumber });
}
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `maintainer @${pr.user.login}`,
}),
skipped: true,
};
}
const analysis = analyzePullRequest(pr);
if (dryRun) {
const summary = summarizeResult({ pr, analysis });
core.notice(
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
);
return {
blocked: analysis.blockers.length > 0,
number: pr.number,
summary,
skipped: false,
};
}
await syncLabels({
github,
owner,
repo,
issueNumber,
desiredLabels: analysis.desiredLabels,
});
if (analysis.blockers.length > 0) {
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
await requestMaintainerReview({ github, owner, repo, pr });
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
async function listOpenPullRequests({ github, owner, repo }) {
return github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
}
function getManualPullRequestNumbers({ context, core }) {
const value = String(context.payload.inputs?.pr || "all").trim();
if (value.toLowerCase() === "all") {
return null;
}
const pullNumber = Number(value);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
core.setFailed('The "pr" input must be "all" or a positive PR number.');
return [];
}
return [pullNumber];
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRunInput = context.payload.inputs?.dry_run;
const dryRun =
context.eventName === "workflow_dispatch" &&
dryRunInput !== false &&
dryRunInput !== "false";
const skipCreatedBefore =
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
let pullNumbers;
if (payloadPr != null) {
pullNumbers = [payloadPr.number];
} else {
pullNumbers = getManualPullRequestNumbers({ context, core });
}
if (pullNumbers?.length === 0) {
return;
}
const pullRequests =
pullNumbers == null
? await listOpenPullRequests({ github, owner, repo })
: pullNumbers.map((number) => ({ number }));
const results = [];
if (dryRun) {
core.notice(
`Running contribution policy in dry-run mode for ${
pullNumbers == null
? "all open PRs"
: pullNumbers.map((number) => `#${number}`).join(", ")
}.`,
);
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
skipCreatedBefore,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Title", header: true },
{ data: "Status", header: true },
{ data: "Labels", header: true },
{ data: "Details", header: true },
{ data: "Comment", header: true },
],
...results.map((result) => [
result.summary.prLink,
result.summary.title,
result.summary.status,
result.summary.labels,
result.summary.details,
result.summary.comment,
]),
])
.write();
const blockedPullRequests = results.filter((result) => result.blocked);
if (blockedPullRequests.length > 0) {
if (dryRun) {
core.warning(
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
return;
}
core.setFailed(
`Contribution policy failed for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
}
}
module.exports = {
analyzePullRequest,
run,
};
+47
View File
@@ -0,0 +1,47 @@
name: Contribution Policy
on:
workflow_dispatch:
inputs:
pr:
description: PR number or all
required: true
default: all
type: string
dry_run:
description: Dry run
required: true
default: true
type: boolean
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
- labeled
- unlabeled
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
name: Check contribution policy
runs-on: ubuntu-latest
steps:
- name: Checkout policy script
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha || github.ref }}
fetch-depth: 1
- name: Check contribution policy
uses: actions/github-script@v7
with:
script: |
const { run } = require("./.github/scripts/check-contribution-policy.js");
await run({ github, context, core });
+9 -6
View File
@@ -1,7 +1,12 @@
name: Update Flathub name: Update Flathub
on: on:
release: workflow_dispatch:
types: [published] inputs:
tag:
description: Release tag to publish to Flathub
required: true
type: string
permissions: permissions:
contents: read contents: read
@@ -10,8 +15,6 @@ jobs:
update-flathub: update-flathub:
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps: steps:
- name: Checkout app repo - name: Checkout app repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,7 +42,7 @@ jobs:
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh - name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo
- name: Commit and push to Flathub - name: Commit and push to Flathub
working-directory: flathub-repo working-directory: flathub-repo
@@ -48,5 +51,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0 git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}" git commit -m "Update to ${{ inputs.tag }}"
git push git push
+11 -3
View File
@@ -24,12 +24,12 @@ jobs:
os: "macos" os: "macos"
targets: "x86_64-apple-darwin" targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: "" args: "--no-default-features --features updater,license,cef"
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: "" args: "--no-default-features --features updater,license,cef"
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" os: "ubuntu"
targets: "" targets: ""
@@ -66,11 +66,18 @@ jobs:
shared-key: ci shared-key: ci
cache-on-failure: true cache-on-failure: true
- name: Cache CEF (Linux only)
if: matrix.os == 'ubuntu'
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 patchelf xdg-utils
- name: Install Protoc for plugin-runtime - name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3 uses: arduino/setup-protoc@v3
@@ -150,6 +157,7 @@ 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: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
+1 -2
View File
@@ -3,13 +3,12 @@
Yaak accepts community pull requests for: Yaak accepts community pull requests for:
- Bug fixes - Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes ## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated. If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
## Development Setup ## Development Setup
Generated
+751 -85
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -47,7 +47,11 @@ 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 = "2.7.1"
tauri-plugin-shell = "2.3.5" tauri-plugin-shell = "2.3.5"
@@ -89,3 +93,7 @@ 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" }
+2 -2
View File
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements. > Community PRs are currently limited to bug fixes.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback). > If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources ## Useful Resources
@@ -10,7 +10,7 @@ export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
id: "folder-settings", id: "folder-settings",
title: null, title: null,
size: "lg", size: "lg",
className: "h-[50rem]", className: "h-200",
noPadding: true, noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />, render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
}); });
@@ -1,19 +1,10 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog"; import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog"; import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) { export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return; if (workspaceId == null) return;
showDialog({ WorkspaceSettingsDialog.show(workspaceId, tab);
id: "workspace-settings",
size: "md",
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
} }
@@ -38,7 +38,7 @@ export function BinaryFileEditor({
<VStack space={2}> <VStack space={2}>
<SelectFile onChange={handleChange} filePath={filePath} /> <SelectFile onChange={handleChange} filePath={filePath} />
{filePath != null && mimeType !== contentType && !ignoreContentType.value && ( {filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5"> <Banner className="mt-3 py-5!">
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div>Set Content-Type header</div> <div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request? <InlineCode>{mimeType}</InlineCode> for current request?
@@ -4,6 +4,7 @@ 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";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
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";
@@ -89,6 +90,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner> </Banner>
)} )}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput <PlainInput
required required
label="Repository URL" label="Repository URL"
@@ -105,7 +108,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
rightSlot={ rightSlot={
<IconButton <IconButton
size="xs" size="xs"
className="mr-0.5 !h-auto my-0.5" className="mr-0.5 h-auto! my-0.5"
icon="folder" icon="folder"
title="Browse" title="Browse"
onClick={handleSelectDirectory} onClick={handleSelectDirectory}
@@ -11,7 +11,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined }; const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames( const finalClassName = classNames(
className, className,
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0", "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
); );
if (onClick) { if (onClick) {
@@ -15,6 +15,7 @@ import {
import { createFolder } from "../commands/commands"; import { createFolder } from "../commands/commands";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { openSettings } from "../commands/openSettings"; import { openSettings } from "../commands/openSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { switchWorkspace } from "../commands/switchWorkspace"; import { switchWorkspace } from "../commands/switchWorkspace";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar"; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment"; import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
import { copyToClipboard } from "../lib/copy"; import { copyToClipboard } from "../lib/copy";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { editEnvironment } from "../lib/editEnvironment"; import { editEnvironment } from "../lib/editEnvironment";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt"; import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { import {
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
action: "settings.show", action: "settings.show",
onSelect: () => openSettings.mutate(null), onSelect: () => openSettings.mutate(null),
}, },
{
key: "workspace_settings.open",
label: "Open Workspace Settings",
action: "workspace_settings.show",
onSelect: () => openWorkspaceSettings(),
},
{ {
key: "app.create", key: "app.create",
label: "Create Workspace", label: "Create Workspace",
@@ -127,13 +133,9 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{ {
key: "cookies.show", key: "cookies.show",
label: "Show Cookies", label: "Show Cookies",
action: "cookies_editor.show",
onSelect: async () => { onSelect: async () => {
showDialog({ CookieDialog.show(activeCookieJar?.id ?? null);
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
}, },
}, },
{ {
@@ -437,7 +439,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
name="command" name="command"
label="Command" label="Command"
placeholder="Search or type a command" placeholder="Search or type a command"
className="font-sans !text-base" className="font-sans text-base!"
defaultValue={command} defaultValue={command}
onChange={handleSetCommand} onChange={handleSetCommand}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
@@ -446,7 +448,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1"> <div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
{filteredGroups.map((g) => ( {filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full"> <div key={g.key} className="mb-1.5 w-full">
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center"> <Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center">
{g.label} {g.label}
</Heading> </Heading>
{g.items.map((v) => ( {g.items.map((v) => (
@@ -489,7 +491,7 @@ function CommandPaletteItem({
color="custom" color="custom"
justify="start" justify="start"
className={classNames( className={classNames(
"w-full h-sm flex items-center rounded px-1.5", "w-full h-sm flex items-center rounded-sm px-1.5",
"hover:text-text", "hover:text-text",
active && "bg-surface-highlight", active && "bg-surface-highlight",
!active && "text-text-subtle", !active && "text-text-subtle",
@@ -0,0 +1,130 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre using Yaak at work, please purchase a license.";
export function CommercialUseBanner({
source,
title,
}: {
source: string;
title: string;
}) {
const [visible, setVisible] = useState(false);
const snoozeStartedRef = useRef(false);
const {
isLoading: isSnoozeLoading,
set: setSnoozedAt,
value: snoozedAt,
} = useKeyValue<string | null>({
namespace: "global",
key: "commercial-use-banner-snoozed-at",
fallback: null,
});
useEffect(() => {
let canceled = false;
shouldShowCommercialUsePrompt()
.then((shouldShow) => {
if (!canceled) setVisible(shouldShow);
})
.catch(console.error);
return () => {
canceled = true;
};
}, [source]);
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
const handleShow = useCallback(() => {
if (snoozeStartedRef.current || snoozed) {
return;
}
snoozeStartedRef.current = true;
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
return null;
}
return (
<div className="w-full">
<DismissibleBanner
id={`commercial-use:${source}`}
color="info"
className="w-full"
onDismiss={() =>
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
}
onShow={handleShow}
actions={[
{
label: "Purchase License",
color: "info",
variant: "solid",
onClick: () => {
openCommercialUsePricing(source).catch(console.error);
},
},
]}
>
<div className="text-sm">
<p className="font-semibold text-text">{title}</p>
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
</div>
</DismissibleBanner>
</div>
);
}
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
if (appInfo.featureLicense !== true) {
return false;
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status === "personal_use";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return false;
}
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
if (value == null) return false;
try {
const snooze = JSON.parse(value) as { at?: unknown };
const at = typeof snooze.at === "string" ? snooze.at : null;
return isWithinMs(at, ms);
} catch {
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
return isWithinMs(value, ms);
}
}
function isWithinMs(date: string | null, ms: number): boolean {
if (date == null) return false;
const time = new Date(date).getTime();
if (Number.isNaN(time)) return false;
return Date.now() - time < ms;
}
+705 -41
View File
@@ -1,9 +1,40 @@
import type { Cookie } from "@yaakapp-internal/models"; import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models"; import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import {
type ComponentProps,
type CSSProperties,
type FormEvent,
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
} from "react";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { cookieDomain } from "../lib/model_util"; import { cookieDomain } from "../lib/model_util";
import { Banner, InlineCode } from "@yaakapp-internal/ui"; import {
Icon,
SplitLayout,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { showAlert } from "../lib/alert";
interface Props { interface Props {
cookieJarId: string | null; cookieJarId: string | null;
@@ -12,56 +43,689 @@ interface Props {
export const CookieDialog = ({ cookieJarId }: Props) => { export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom); const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId); const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
const [filter, setFilter] = useState("");
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
const [draftExpiresInput, setDraftExpiresInput] = useState("");
const editorFormRef = useRef<HTMLFormElement>(null);
const filteredCookies = useMemo(() => {
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
}, [cookieJar?.cookies, filter]);
const selectedCookie = useMemo(
() =>
selectedCookieKey == null
? null
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
[filteredCookies, selectedCookieKey],
);
const detailCookie = draftCookie ?? selectedCookie;
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
const isEditingCookie = draftCookie != null;
const handleAddCookie = () => {
setSelectedCookieKey(null);
setEditingCookieKey(NEW_COOKIE_KEY);
setDraftCookie(newCookieDraft());
setDraftExpiresInput("");
};
const handleEditCookie = () => {
if (selectedCookie == null) {
return;
}
setEditingCookieKey(cookieKey(selectedCookie));
setDraftCookie(selectedCookie);
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
};
const handleCancelEdit = () => {
if (isCreatingCookie) {
setSelectedCookieKey(null);
}
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
const handleCloseDetails = () => {
if (isEditingCookie) {
handleCancelEdit();
return;
}
setSelectedCookieKey(null);
};
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.expires !== "SessionEnd") {
const expires = cookieExpiresFromInput(draftExpiresInput);
if (expires == null) {
showAlert({
id: "invalid-cookie-expires",
title: "Invalid Cookie",
body: "Cookie expiration must be a valid date.",
});
return;
}
nextCookie = { ...nextCookie, expires };
}
const nextCookieKey = cookieKey(nextCookie);
const nextCookies = cookieJar.cookies.filter((cookie) => {
const key = cookieKey(cookie);
if (editingCookieKey != null && key === editingCookieKey) {
return false;
}
return key !== nextCookieKey;
});
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
if (cookieJar == null) { if (cookieJar == null) {
return <div>No cookie jar selected</div>; return <div>No cookie jar selected</div>;
} }
if (cookieJar.cookies.length === 0) { return (
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<PlainInput
name="cookie-filter"
label="Filter cookies"
hideLabel
placeholder="Filter cookies"
defaultValue={filter}
forceUpdateKey={filterUpdateKey}
onChange={setFilter}
rightSlot={
filter.length > 0 && (
<IconButton
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
setFilter("");
setFilterUpdateKey((key) => key + 1);
}}
/>
)
}
/>
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
</div>
{cookieJar.cookies.length === 0 && detailCookie == null ? (
<EmptyStateText>
Cookies will appear when a response includes a Set-Cookie header.
</EmptyStateText>
) : filteredCookies.length === 0 && detailCookie == null ? (
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
) : (
<SplitLayout
layout="vertical"
storageKey="cookie-dialog-details"
defaultRatio={0.5}
className="-mx-2"
minHeightPx={10}
firstSlot={({ style }) =>
filteredCookies.length === 0 ? (
<div style={style}>
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
</div>
) : (
<Table scrollable style={style} className="pr-0.5">
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
<TableHeaderCell>Expires</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>HTTP Only</TableHeaderCell>
<TableHeaderCell>Secure</TableHeaderCell>
<TableHeaderCell>Same Site</TableHeaderCell>
<TableHeaderCell>
<IconButton
icon="list_x"
size="sm"
className="text-text-subtle"
title="Clear all cookies"
onClick={() => {
setSelectedCookieKey(null);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
void patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
{filteredCookies.map((c: Cookie) => {
const key = cookieKey(c);
const isSelected = key === selectedCookieKey;
return (
<TableRow
key={key}
className={classNames(
"group/tr cursor-default",
isSelected && "[&_td]:bg-surface-highlight",
!isSelected && "hover:[&_td]:bg-surface-hover",
)}
onClick={() => {
setSelectedCookieKey(key);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}}
>
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name}
</TableCell>
<TruncatedWideTableCell className="min-w-40">
{c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
<TableCell>{c.path}</TableCell>
<TableCell>{cookieExpires(c)}</TableCell>
<TableCell>{cookieSize(c)}</TableCell>
<TableCell>
<Icon
icon={c.httpOnly ? "check" : "x"}
className={classNames(!c.httpOnly && "opacity-10")}
/>
</TableCell>
<TableCell>
<Icon
icon={c.secure ? "check" : "x"}
className={classNames(!c.secure && "opacity-10")}
/>
</TableCell>
<TableCell>{c.sameSite}</TableCell>
<TableCell className="rounded-r pr-2">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
onClick={(event) => {
event.stopPropagation();
if (isSelected) {
setSelectedCookieKey(null);
}
if (editingCookieKey === key) {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}
void patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)
}
secondSlot={
detailCookie == null
? null
: ({ style }) => (
<CookieDetailsPane
formRef={editorFormRef}
isEditing={isEditingCookie}
onSubmit={handleSaveCookie}
style={style}
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: () => editorFormRef.current?.requestSubmit(),
},
{
key: "cancel",
label: "Cancel",
onClick: handleCancelEdit,
},
]
: [
{
key: "edit",
label: "Edit",
onClick: handleEditCookie,
},
]
}
onClose={handleCloseDetails}
/>
{isEditingCookie ? (
<CookieEditor
cookie={detailCookie}
expiresInputValue={draftExpiresInput}
onChange={setDraftCookie}
onExpiresInputChange={setDraftExpiresInput}
/>
) : (
<CookieDetails cookie={detailCookie} />
)}
</CookieDetailsPane>
)
}
/>
)}
</div>
);
};
function CookieDetailsPane({
children,
formRef,
isEditing,
onSubmit,
style,
}: {
children: ReactNode;
formRef: RefObject<HTMLFormElement | null>;
isEditing: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
style: CSSProperties;
}) {
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
if (isEditing) {
return ( return (
<Banner> <form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header {children}
</Banner> </form>
); );
} }
return ( return (
<div className="pb-2"> <div style={style} className={className}>
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> {children}
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
<th className="py-2 pl-4" />
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{cookieJar?.cookies.map((c: Cookie) => (
<tr key={JSON.stringify(c)}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="ml-auto"
onClick={() =>
patchModel(cookieJar, {
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
); );
}
CookieDialog.show = (cookieJarId: string | null) => {
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
if (cookieJar == null) {
showAlert({
id: "invalid-jar",
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
title: "Invalid Cookie Jar",
});
return;
}
showDialog({
id: "cookies",
title: `${cookieJar.name} Cookies`,
size: "full",
render: () => <CookieDialog cookieJarId={cookieJarId} />,
});
}; };
function CookieDetails({ cookie }: { cookie: Cookie }) {
return (
<div className="overflow-y-auto">
<KeyValueRows selectable>
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
</CookieKeyValueRow>
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
{cookie.sameSite && (
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
)}
</KeyValueRows>
</div>
);
}
function CookieEditor({
cookie,
expiresInputValue,
onChange,
onExpiresInputChange,
}: {
cookie: Cookie;
expiresInputValue: string;
onChange: (cookie: Cookie) => void;
onExpiresInputChange: (value: string) => void;
}) {
const sessionCookie = cookie.expires === "SessionEnd";
return (
<div className="overflow-y-auto">
<KeyValueRows>
<CookieKeyValueRow align="middle" label="Name">
<CookieTextInput
required
autoFocus
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookie.name}
onChange={(name) => onChange({ ...cookie, name })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Value">
<CookieTextarea
value={cookie.value}
onChange={(value) => onChange({ ...cookie, value })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Domain">
<CookieTextInput
required
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookieDomainInputValue(cookie)}
placeholder="example.com"
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Path">
<CookieTextInput
value={cookie.path}
placeholder="/"
onChange={(path) => onChange({ ...cookie, path })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">
<div className="grid gap-1">
<Checkbox
checked={sessionCookie}
title="Session cookie"
onChange={(checked) => {
if (checked) {
onChange({ ...cookie, expires: "SessionEnd" });
return;
}
const expiresInput =
cookieExpiresFromInput(expiresInputValue) == null
? defaultCookieExpiresInputValue()
: expiresInputValue;
onExpiresInputChange(expiresInput);
onChange({
...cookie,
expires: cookieExpiresFromInput(expiresInput)!,
});
}}
/>
<CookieTextInput
value={sessionCookie ? "" : expiresInputValue}
disabled={sessionCookie}
onChange={(value) => {
onExpiresInputChange(value);
const expires = cookieExpiresFromInput(value);
if (expires != null) {
onChange({ ...cookie, expires });
}
}}
/>
</div>
</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="HTTP Only">
<Checkbox
hideLabel
title="HTTP Only"
checked={cookie.httpOnly}
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Secure">
<Checkbox
hideLabel
title="Secure"
checked={cookie.secure}
onChange={(secure) => onChange({ ...cookie, secure })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Same Site">
<Select
hideLabel
name="cookie-same-site"
label="Same Site"
value={cookie.sameSite ?? ""}
size="xs"
className="w-full"
options={[
{ label: "n/a", value: "" },
{ label: "Lax", value: "Lax" },
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/>
</CookieKeyValueRow>
</KeyValueRows>
</div>
);
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
}
function CookieTextInput({
autoFocus,
disabled,
onChange,
pattern,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
pattern?: string;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
autoCapitalize="off"
autoCorrect="off"
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
pattern={pattern}
placeholder={placeholder}
required={required}
type="text"
value={value}
/>
);
}
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-20 resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
);
}
const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-hidden",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
function cookieSize(cookie: Cookie) {
const encoder = new TextEncoder();
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
}
function newCookieDraft(): Cookie {
return {
name: "",
value: "",
domain: "NotPresent",
expires: "SessionEnd",
path: "/",
secure: false,
httpOnly: false,
sameSite: null,
};
}
function normalizeCookie(cookie: Cookie): Cookie {
return {
...cookie,
domain: normalizeCookieDomain(cookie.domain),
name: cookie.name.trim(),
path: cookie.path.trim() || "/",
};
}
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
if (domain === "NotPresent" || domain === "Empty") {
return domain;
}
if ("Suffix" in domain) {
return { Suffix: domain.Suffix.trim() };
}
return { HostOnly: domain.HostOnly.trim() };
}
function cookieDomainInputValue(cookie: Cookie) {
const domain = cookieDomain(cookie);
return domain === "n/a" ? "" : domain;
}
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { ...cookie, domain: "NotPresent" };
}
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
return { ...cookie, domain: { Suffix: trimmedDomain } };
}
return { ...cookie, domain: { HostOnly: trimmedDomain } };
}
function cookieExpires(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "Session";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return cookie.expires.AtUtc;
}
const date = new Date(expiresSeconds * 1000);
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
}
function cookieExpiresInputValue(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return "";
}
return new Date(expiresSeconds * 1000).toISOString();
}
function defaultCookieExpiresInputValue() {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
return null;
}
return { AtUtc: `${Math.floor(time / 1000)}` };
}
function cookieMatchesFilter(cookie: Cookie, filter: string) {
const query = filter.trim().toLowerCase();
if (query.length === 0) {
return true;
}
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
value.toLowerCase().includes(query),
);
}
function cookieKey(cookie: Cookie) {
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
}
function cookieDomainKey(domain: Cookie["domain"]) {
if (typeof domain !== "string" && "HostOnly" in domain) {
return `HostOnly:${domain.HostOnly}`;
}
if (typeof domain !== "string" && "Suffix" in domain) {
return `Suffix:${domain.Suffix}`;
}
return domain;
}
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar"; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useCreateCookieJar } from "../hooks/useCreateCookieJar"; import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt"; import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams"; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from "./CookieDialog"; import { CookieDialog } from "./CookieDialog";
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
leftSlot: <Icon icon="cookie" />, leftSlot: <Icon icon="cookie" />,
onSelect: () => { onSelect: () => {
if (activeCookieJar == null) return; if (activeCookieJar == null) return;
showDialog({ CookieDialog.show(activeCookieJar.id);
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
}, },
}, },
{ {
@@ -75,7 +75,7 @@ export function DnsOverridesEditor({ workspace }: Props) {
<VStack space={3} className="pb-3"> <VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm"> <div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{" "} Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but <code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
only for requests made from this workspace. only for requests made from this workspace.
</div> </div>
+2 -2
View File
@@ -23,8 +23,8 @@ export const DropMarker = memo(
<div <div
className={classNames( className={classNames(
"absolute bg-primary rounded-full", "absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]", orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]", orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
)} )}
/> />
</div> </div>
+5 -5
View File
@@ -204,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
summary={input.label} summary={input.label}
className={classNames("!mb-auto", disabled && "opacity-disabled")} className={classNames("mb-auto!", disabled && "opacity-disabled")}
> >
<div className="mt-3"> <div className="mt-3">
<FormInputsStack <FormInputsStack
@@ -300,7 +300,7 @@ function TextArg({
onChange, onChange,
name: arg.name, name: arg.name,
multiLine: arg.multiLine, multiLine: arg.multiLine,
className: arg.multiLine ? "min-h-[4rem]" : undefined, className: arg.multiLine ? "min-h-16" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value, defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional, required: !arg.optional,
disabled: arg.disabled, disabled: arg.disabled,
@@ -359,7 +359,7 @@ function EditorArg({
className={classNames( className={classNames(
"border border-border rounded-md overflow-hidden px-2 py-1", "border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus", "focus-within:border-border-focus",
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space !arg.rows && "max-h-40", // So it doesn't take up too much space
)} )}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined} style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
> >
@@ -372,7 +372,7 @@ function EditorArg({
onChange={onChange} onChange={onChange}
hideGutter hideGutter
heightMode="auto" heightMode="auto"
className="min-h-[3rem]" className="min-h-12"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value} defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined} placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
@@ -392,7 +392,7 @@ function EditorArg({
id: "id", id: "id",
size: "full", size: "full",
title: arg.readOnly ? "View Value" : "Edit Value", title: arg.readOnly ? "View Value" : "Edit Value",
className: "!max-w-[50rem] !max-h-[60rem]", className: "max-w-200! max-h-240!",
description: arg.label && ( description: arg.label && (
<Label <Label
htmlFor={id} htmlFor={id}
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
wrapperClassName?: string;
} }
export function EmptyStateText({ children, className }: Props) { export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return ( return (
<div className="w-full h-full pb-2"> <div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div <div
className={classNames( className={classNames(
className, className,
@@ -62,7 +62,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text !px-2 truncate", "text px-2! truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic", !activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)} )}
// If no environments, the button simply opens the dialog. // If no environments, the button simply opens the dialog.
@@ -57,7 +57,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
defaultRatio={0.75} defaultRatio={0.75}
layout="horizontal" layout="horizontal"
className="gap-0" className="gap-0"
resizeHandleClassName="-translate-x-[1px]" resizeHandleClassName="-translate-x-px"
firstSlot={() => ( firstSlot={() => (
<EnvironmentEditDialogSidebar <EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null} selectedEnvironmentId={selectedEnvironment?.id ?? null}
@@ -8,6 +8,7 @@ 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 { invokeCmd } from "../lib/tauri";
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";
@@ -85,8 +86,10 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]"> <div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead> <thead>
<tr> <tr>
@@ -137,9 +140,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
+2 -2
View File
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
return ( return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden"> <div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0"> <code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url} {request.method} {request.url}
</code> </code>
{latestResponse ? ( {latestResponse ? (
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
className={classNames( className={classNames(
"cursor-default select-none", "cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full", "font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
)} )}
> >
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />} {latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
@@ -21,6 +21,7 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
interface Props { interface Props {
folderId: string | null; folderId: string | null;
@@ -29,6 +30,7 @@ interface Props {
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_SETTINGS = "settings";
const TAB_VARIABLES = "variables"; const TAB_VARIABLES = "variables";
const TAB_GENERAL = "general"; const TAB_GENERAL = "general";
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
| typeof TAB_HEADERS | typeof TAB_HEADERS
| typeof TAB_GENERAL | typeof TAB_GENERAL
| typeof TAB_SETTINGS
| typeof TAB_VARIABLES; | typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
(e) => e.parentModel === "folder" && e.parentId === folderId, (e) => e.parentModel === "folder" && e.parentId === folderId,
); );
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length; const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
const tabs = useMemo<TabItem[]>(() => { const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return []; if (folder == null) return [];
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
value: TAB_GENERAL, value: TAB_GENERAL,
label: "General", label: "General",
}, },
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
...headersTab, ...headersTab,
...authTab, ...authTab,
{ {
@@ -68,19 +77,19 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null, rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
}, },
]; ];
}, [authTab, folder, headersTab, numVars]); }, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
if (folder == null) return null; if (folder == null) return null;
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl"> <div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" /> <Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1"> <div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => ( {breadcrumbs.map((item, index) => (
<Fragment key={item.id}> <Fragment key={item.id}>
{index > 0 && ( {index > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)} )}
<span className="text-text-subtle truncate min-w-0" title={item.name}> <span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name} {item.name}
@@ -88,7 +97,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
</Fragment> </Fragment>
))} ))}
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)} )}
<span className="whitespace-nowrap" title={folder.name}> <span className="whitespace-nowrap" title={folder.name}>
{folder.name} {folder.name}
@@ -140,7 +149,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id} {folder.id}
<CopyIconButton <CopyIconButton
className="opacity-70 !text-primary" className="opacity-70 text-primary!"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy folder ID" title="Copy folder ID"
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`headers.${folder.id}`} stateKey={`headers.${folder.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<ModelSettingsEditor model={folder} />
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? ( {folderEnvironment == null ? (
<EmptyStateText> <EmptyStateText>
+1 -1
View File
@@ -126,7 +126,7 @@ export function GrpcEditor({
const actions = useMemo( const actions = useMemo(
() => [ () => [
<div key="reflection" className={classNames(services == null && "!opacity-100")}> <div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button <Button
size="xs" size="xs"
color={ color={
@@ -20,6 +20,7 @@ import { GrpcEditor } from "./GrpcEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
interface Props { interface Props {
@@ -47,6 +48,7 @@ interface Props {
const TAB_MESSAGE = "message"; const TAB_MESSAGE = "message";
const TAB_METADATA = "metadata"; const TAB_METADATA = "metadata";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({ export function GrpcRequestPane({
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata"); const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null); const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
{ value: TAB_MESSAGE, label: "Message" }, { value: TAB_MESSAGE, label: "Message" },
...metadataTab, ...metadataTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />, rightSlot: activeRequest.description && <CountBadge count={true} />,
}, },
], ],
[activeRequest.description, authTab, metadataTab], [activeRequest.description, authTab, metadataTab, numSettingsOverrides],
); );
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
@@ -154,7 +162,7 @@ export function GrpcRequestPane({
className={classNames( className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5", "grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0", paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1", paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
)} )}
> >
<UrlBar <UrlBar
@@ -193,7 +201,7 @@ export function GrpcRequestPane({
rightSlot={<Icon size="sm" icon="chevron_down" />} rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null} disabled={isStreaming || services == null}
className={classNames( className={classNames(
"font-mono text-editor min-w-[5rem] !ring-0", "font-mono text-editor min-w-20 ring-0!",
paneWidth < 400 && "flex-1", paneWidth < 400 && "flex-1",
)} )}
> >
@@ -251,7 +259,7 @@ export function GrpcRequestPane({
<Tabs <Tabs
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 mb-1.5!"
storageKey="grpc_request_tabs" storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id} activeTabKey={activeRequest.id}
> >
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
onChange={handleMetadataChange} onChange={handleMetadataChange}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}> <TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput <PlainInput
@@ -285,7 +296,7 @@ export function GrpcRequestPane({
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
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) => patchModel(activeRequest, { name })}
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react"; import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig"; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication"; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl"; import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }), async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode> Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-1"> <EmptyStateText className="flex-col gap-3">
<p> <div className="not-italic flex flex-col items-center gap-3 text-center">
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> <p className="max-w-md text-sm text-text-subtle">
</p> Choose an auth method to apply it to all requests in{" "}
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link> <strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p>
<AuthenticationTypeDropdown model={model} />
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText> </EmptyStateText>
); );
} }
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth"); if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled == null model.authentication.disabled === false ||
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -140,7 +156,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
title="Authentication Actions" title="Authentication Actions"
icon="settings" icon="settings"
size="xs" size="xs"
className="!text-secondary" className="text-secondary!"
/> />
</Dropdown> </Dropdown>
)} )}
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full" className="w-full"
stateKey={`auth.${model.id}.dynamic`} stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled} value={model.authentication.disabled}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })} onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -169,6 +187,33 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
} }
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({ function AuthenticationDisabledInput({
value, value,
onChange, onChange,
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"} {rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -57,7 +57,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
<GraphQLDocsExplorer <GraphQLDocsExplorer
requestId={activeRequest.id} requestId={activeRequest.id}
schema={graphQLSchema} schema={graphQLSchema}
className={classNames(orientation === "horizontal" && "!ml-0")} className={classNames(orientation === "horizontal" && "ml-0!")}
style={style} style={style}
/> />
)} )}
@@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms"; 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 { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -51,6 +52,7 @@ import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor"; import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -69,6 +71,7 @@ const TAB_BODY = "body";
const TAB_PARAMS = "params"; const TAB_PARAMS = "params";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
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";
@@ -92,6 +95,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL) // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent( useRequestEditorEvent(
@@ -128,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -234,6 +236,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
@@ -246,6 +253,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
handleContentTypeChange, handleContentTypeChange,
headersTab, headersTab,
numParams, numParams,
numSettingsOverrides,
urlParameterPairs.length, urlParameterPairs.length,
], ],
); );
@@ -338,7 +346,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onUrlChange={handleUrlChange} onUrlChange={handleUrlChange}
leftSlot={ leftSlot={
<div className="py-0.5"> <div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" /> <RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div> </div>
} }
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
@@ -372,6 +380,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}> <ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
@@ -445,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
hideLabel hideLabel
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
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) => patchModel(activeRequest, { name })}
@@ -4,10 +4,12 @@ import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react"; import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react"; import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
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 { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType"; import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
@@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.state === "closed" && redirectDropWarning != null; activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
? [] ? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
], ],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
}, },
}, },
{ {
@@ -135,12 +155,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
], ],
[ [
activeResponse?.headers, activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength, activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length, activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent, cookieCounts.sent,
cookieCounts.received, cookieCounts.received,
copyResponse.mutate,
mimeType, mimeType,
responseEvents.data?.length, responseEvents.data?.length,
saveResponse.mutate,
setViewMode, setViewMode,
viewMode, viewMode,
timelineViewMode, timelineViewMode,
@@ -167,7 +193,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack
className={classNames( className={classNames(
"text-text-subtle w-full flex-shrink-0", "text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too // Remove a bit of space because the tabs have lots too
"-mb-1.5", "-mb-1.5",
)} )}
@@ -180,7 +206,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)} )}
> >
<HStack space={2} className="w-full flex-shrink-0"> <HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />} {activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} /> <HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span> <span>&bull;</span>
@@ -194,7 +220,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
{shouldShowRedirectDropWarning ? ( {shouldShowRedirectDropWarning ? (
<Tooltip <Tooltip
tabIndex={0} tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden" className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={ content={
<VStack alignItems="start" space={1} className="text-xs"> <VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning"> <span className="font-medium text-warning">
@@ -223,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<span className="inline-flex min-w-0"> <span className="inline-flex min-w-0">
<PillButton <PillButton
color="warning" color="warning"
className="font-sans text-sm !flex-shrink max-w-full" className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center" innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />} leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
> >
@@ -236,7 +262,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : ( ) : (
<span /> <span />
)} )}
<div className="justify-self-end flex-shrink-0"> <div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown <RecentHttpResponsesDropdown
responses={responses} responses={responses}
activeResponse={activeResponse} activeResponse={activeResponse}
@@ -249,7 +275,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="overflow-hidden flex flex-col min-h-0"> <div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && ( {activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0"> <Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error} {activeResponse.error}
</Banner> </Banner>
)} )}
@@ -1,10 +1,15 @@
import type { import type {
AnyModel,
HttpResponse, HttpResponse,
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { type ReactNode, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { useAllRequests } from "../hooks/useAllRequests";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow"; import { EventViewerRow } from "./core/EventViewerRow";
@@ -95,6 +100,7 @@ function EventDetails({
}) { }) {
const { label } = getEventDisplay(event.event); const { label } = getEventDisplay(event.event);
const e = event.event; const e = event.event;
const settingSourceModels = useSettingSourceModels();
const actions: EventDetailAction[] = [ const actions: EventDetailAction[] = [
{ {
@@ -211,6 +217,9 @@ function EventDetails({
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow> <KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
{e.source_model != null ? (
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
) : null}
</KeyValueRows> </KeyValueRows>
); );
} }
@@ -315,6 +324,44 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
return includePrefix ? `${prefix} ${text}` : text; return includePrefix ? `${prefix} ${text}` : text;
} }
function useSettingSourceModels() {
const requests = useAllRequests();
const folders = useAtomValue(foldersAtom);
const workspaces = useAtomValue(workspacesAtom);
return useMemo<AnyModel[]>(
() => [...requests, ...folders, ...workspaces],
[requests, folders, workspaces],
);
}
function formatSettingSource(
event: Extract<HttpResponseEventData, { type: "setting" }>,
models: AnyModel[],
): string {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default") {
return "Default";
}
const model =
event.source_id == null
? null
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
const name = model == null ? event.source_name : resolvedModelName(model);
const label = sourceModel.replaceAll("_", " ");
return name == null || name.length === 0 ? label : `${name} (${label})`;
}
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
return null;
}
return sourceModel;
}
type EventDisplay = { type EventDisplay = {
icon: IconProps["icon"]; icon: IconProps["icon"];
color: IconProps["color"]; color: IconProps["color"];
@@ -325,11 +372,12 @@ type EventDisplay = {
function getEventDisplay(event: HttpResponseEventData): EventDisplay { function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) { switch (event.type) {
case "setting": case "setting":
const sourceModel = formatSettingSourceModel(event);
return { return {
icon: "settings", icon: "settings",
color: "secondary", color: "secondary",
label: "Setting", label: "Setting",
summary: `${event.name} = ${event.value}`, summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
}; };
case "info": case "info":
return { return {
@@ -1,6 +1,7 @@
import { VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
@@ -14,6 +15,8 @@ export function ImportDataDialog({ importData }: Props) {
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}> <VStack space={1}>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li> <li>OpenAPI 3.0, 3.1</li>
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const actions = useMemo<EditorProps["actions"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
showBanner && ( showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs"> <Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0"> <p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span> <span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" /> <Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p> </p>
</Banner> </Banner>
), ),
<div key="settings" className="!opacity-100 !shadow"> <div key="settings" className="opacity-100! shadow!">
<Dropdown <Dropdown
onOpen={handleDropdownOpen} onOpen={handleDropdownOpen}
items={ items={
+1 -1
View File
@@ -59,7 +59,7 @@ function getDetail(
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`, label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
}, },
{ {
label: <div className="min-w-[12rem]">Renew License</div>, label: <div className="min-w-48">Renew License</div>,
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,
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor <Editor
hideGutter hideGutter
wrapLines wrapLines
className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")} className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")}
language="markdown" language="markdown"
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onChange} onChange={onChange}
@@ -46,7 +46,7 @@ export function MarkdownEditor({
defaultValue.length === 0 ? ( defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p> <p className="text-text-subtlest">No description</p>
) : ( ) : (
<div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto"> <div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto">
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown> <Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
</div> </div>
); );
@@ -0,0 +1,634 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
InheritedBoolSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { useModelAncestors } from "../hooks/useModelAncestors";
import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import {
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingsList,
SettingsSection,
} from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props {
showSectionTitles?: boolean;
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return (
<SettingsList className="space-y-8">
{supportsTlsSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
{supportsHttpSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_TIMEOUT}
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_TIMEOUT.modelKey,
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
)}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_VALIDATE_CERTIFICATES.modelKey,
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchTlsSettings(model, {
settingValidateCertificates,
})
}
/>
{supportsHttpSettings && (
<BooleanSettingRow
settingDefinition={SETTING_FOLLOW_REDIRECTS}
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_FOLLOW_REDIRECTS.modelKey,
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_SEND_COOKIES.modelKey,
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
settingDefinition={SETTING_STORE_COOKIES}
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_STORE_COOKIES.modelKey,
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
)}
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
}
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
}
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
}
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
}
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function patchMessageSizeSettings(
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: boolean;
setting: BooleanSetting;
settingDefinition: RequestSettingDefinition;
onChange: (setting: BooleanSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
<SettingRowBoolean
checked={value}
title={settingDefinition.title}
description={settingDefinition.description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={settingDefinition.title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseInteger(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value}
inputMode="decimal"
step="any"
placeholder={placeholder}
validate={isValidMegabytes}
onChange={onChange}
/>
);
}
function NumberUnitInput({
inputMode,
label,
name,
onChange,
placeholder,
step,
unit,
validate,
value,
}: {
inputMode?: "decimal" | "numeric";
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
step?: number | "any";
unit: string;
validate: (value: string) => boolean;
value: string;
}) {
return (
<PlainInput
hideLabel
name={name}
label={label}
size="sm"
type="number"
inputMode={inputMode}
step={step}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="w-48!"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
{unit}
</span>
}
onChange={onChange}
/>
);
}
function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } {
return typeof setting === "object" && setting != null && "value" in setting;
}
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
}
continue;
}
return setting;
}
return isInheritedSetting(fallback) ? fallback.value : fallback;
}
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
const megabytes = Number(value);
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
}
function parseInteger(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function isValidInteger(value: string) {
const parsed = Number(value);
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
}
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
}
@@ -66,7 +66,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
<Button <Button
size="xs" size="xs"
color="secondary" color="secondary"
className="mr-auto min-w-[5rem]" className="mr-auto min-w-20"
onClick={async () => { onClick={async () => {
await router.navigate({ await router.navigate({
to: "/workspaces/$workspaceId", to: "/workspaces/$workspaceId",
+7 -5
View File
@@ -1,3 +1,5 @@
@reference "../main.css";
.prose { .prose {
@apply text-text; @apply text-text;
@@ -98,7 +100,7 @@
@apply text-notice hover:underline; @apply text-notice hover:underline;
* { * {
@apply text-notice !important; @apply text-notice!;
} }
} }
@@ -113,12 +115,12 @@
ol code, ol code,
ul code { ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap; @apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded not-italic; @apply px-1.5 py-0.5 rounded-sm not-italic;
@apply select-text; @apply select-text;
} }
pre { pre {
@apply bg-surface-highlight text-text !important; @apply bg-surface-highlight! text-text!;
@apply px-4 py-3 rounded-md; @apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre; @apply overflow-auto whitespace-pre;
@apply text-editor font-mono; @apply text-editor font-mono;
@@ -130,7 +132,7 @@
.banner { .banner {
@apply border border-dashed; @apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base; @apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base;
&::before { &::before {
@apply block font-bold mb-1; @apply block font-bold mb-1;
@@ -161,7 +163,7 @@
} }
blockquote { blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg; @apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg;
p { p {
@apply m-0; @apply m-0;
@@ -1,10 +1,17 @@
import type { GrpcConnection } from "@yaakapp-internal/models"; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({
}: Props) { }: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -36,16 +100,7 @@ export function RecentGrpcConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -1,13 +1,21 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useSaveResponse } from "../hooks/useSaveResponse"; import { useKeyValue } from "../hooks/useKeyValue";
import { pluralize } from "../lib/pluralize"; import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -22,32 +30,93 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a"; const latestResponseId = responses[0]?.id ?? "n/a";
const saveResponse = useSaveResponse(activeResponse); const responseHistoryItems: DropdownItem[] = [];
const copyResponse = useCopyHttpResponse(activeResponse); let lastHistoryGroup: string | null = null;
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return ( return (
<Dropdown <Dropdown
items={[ items={[
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{ {
label: "Delete", label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse), onSelect: () => deleteModel(activeResponse),
}, },
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{ {
label: "Unpin Response", label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id), onSelect: () => onPinnedResponseId(activeResponse.id),
@@ -55,25 +124,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: "separator", label: "History" },
{ {
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`, type: "content",
onSelect: deleteAllResponses.mutate, hidden: dismissedMovedActions === true,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{ type: "separator" },
...responses.map((r: HttpResponse) => ({
label: ( label: (
<HStack space={2}> <DismissibleBanner
<HttpStatusTag short className="text-xs" response={r} /> id={movedActionsBannerId}
<span className="text-text-subtle">&rarr;</span>{" "} color="info"
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span> size="xs"
</HStack> className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, },
onSelect: () => onPinnedResponseId(r.id), {
})), type: "separator",
label: "Recent",
},
...responseHistoryItems,
]} ]}
> >
<IconButton <IconButton
@@ -1,10 +1,17 @@
import type { WebsocketConnection } from "@yaakapp-internal/models"; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -19,6 +26,63 @@ export function RecentWebsocketConnectionsDropdown({
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -40,16 +104,7 @@ export function RecentWebsocketConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -167,7 +167,7 @@ export function ResponseCookies({ response }: Props) {
{cookie.value} {cookie.value}
</span> </span>
{cookie.isDeleted && ( {cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded"> <span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
Deleted Deleted
</span> </span>
)} )}
@@ -1,5 +1,6 @@
import { openUrl } from "@tauri-apps/plugin-opener"; 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 { useMemo } from "react"; import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -29,12 +30,20 @@ export function ResponseHeaders({ response }: Props) {
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5"> <div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}> <DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows> <KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL"> <KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span> <span className="select-text cursor-text">{response.url}</span>
<IconButton <IconButton
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={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -24,7 +24,7 @@ export function ResponseInfo({ response }: Props) {
URL URL
<IconButton <IconButton
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={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -10,7 +10,7 @@ export default function RouteError({ error }: { error: unknown }) {
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null; typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<VStack space={5} className="w-[50rem] !h-auto"> <VStack space={5} className="w-200 h-auto!">
<Heading>Route Error 🔥</Heading> <Heading>Route Error 🔥</Heading>
<FormattedError> <FormattedError>
{message} {message}
+4 -2
View File
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
inline?: boolean; inline?: boolean;
noun?: string; noun?: string;
help?: ReactNode; help?: ReactNode;
hideLabel?: boolean;
label?: ReactNode; label?: ReactNode;
}; };
@@ -36,6 +37,7 @@ export function SelectFile({
size = "sm", size = "sm",
label, label,
help, help,
hideLabel,
...props ...props
}: Props) { }: Props) {
const handleClick = async () => { const handleClick = async () => {
@@ -95,7 +97,7 @@ export function SelectFile({
return ( return (
<div ref={ref} className="w-full"> <div ref={ref} className="w-full">
{label && ( {label && (
<Label htmlFor={null} help={help}> <Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
{label} {label}
</Label> </Label>
)} )}
@@ -106,7 +108,7 @@ export function SelectFile({
"rtl mr-1.5", "rtl mr-1.5",
inline && "w-full", inline && "w-full",
filePath && inline && "font-mono text-xs", filePath && inline && "font-mono text-xs",
isHovering && "!border-notice", isHovering && "border-notice!",
)} )}
color={isHovering ? "primary" : "secondary"} color={isHovering ? "primary" : "secondary"}
onClick={handleClick} onClick={handleClick}
@@ -93,7 +93,7 @@ export default function Settings({ hide }: Props) {
layout="horizontal" layout="horizontal"
defaultValue={mainTab || tabFromQuery} defaultValue={mainTab || tabFromQuery}
addBorders addBorders
tabListClassName="min-w-[10rem] 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"
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
@@ -131,28 +131,28 @@ export default function Settings({ hide }: Props) {
}), }),
)} )}
> >
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral /> <SettingsGeneral />
</TabContent> </TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface /> <SettingsInterface />
</TabContent> </TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme /> <SettingsTheme />
</TabContent> </TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy /> <SettingsProxy />
</TabContent> </TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates /> <SettingsCertificates />
</TabContent> </TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense /> <SettingsLicense />
</TabContent> </TabContent>
</Tabs> </Tabs>
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
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";
@@ -232,6 +233,8 @@ export function SettingsCertificates() {
</HStack> </HStack>
</div> </div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && ( {certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
@@ -2,174 +2,168 @@ 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";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal"; import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Checkbox } from "../core/Checkbox"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow"; import {
import { PlainInput } from "../core/PlainInput"; ModelSettingRowBoolean,
import { Select } from "../core/Select"; ModelSettingSelectControl,
import { Separator } from "../core/Separator"; SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) { if (settings == null) {
return null; return null;
} }
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-4"> <div>
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p> <p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div> </div>
<CargoFeature feature="updater"> <div className="mt-3 mb-5">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1"> <CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
<Select </div>
name="updateChannel" <SettingsList className="space-y-8">
label="Update Channel" <CargoFeature feature="updater">
labelPosition="left" <SettingsSection title="Updates">
labelClassName="w-[14rem]" <SettingRow
size="sm" title="Update Channel"
value={settings.updateChannel} description="Choose whether Yaak should use stable releases or beta releases."
onChange={(updateChannel) => patchModel(settings, { updateChannel })} >
options={[ <div className="grid grid-cols-[12rem_auto] gap-1">
{ label: "Stable", value: "stable" }, <ModelSettingSelectControl
{ label: "Beta (more frequent)", value: "beta" }, model={settings}
]} modelKey="updateChannel"
/> label="Update Channel"
<IconButton selectClassName="w-full!"
variant="border" options={[
size="sm" { label: "Stable", value: "stable" },
title="Check for updates" { label: "Beta", value: "beta" },
icon="refresh" ]}
spin={checkForUpdates.isPending} />
onClick={() => checkForUpdates.mutateAsync()} <IconButton
/> variant="border"
</div> size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<Select <SettingRowSelect
name="autoupdate" title="Update Behavior"
value={settings.autoupdate ? "auto" : "manual"} description="Choose whether updates are installed automatically or manually."
label="Update Behavior" name="autoupdate"
labelPosition="left" value={settings.autoupdate ? "auto" : "manual"}
size="sm" onChange={(v) =>
labelClassName="w-[14rem]" patchModel(settings, { autoupdate: v === "auto" })
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })} }
options={[ options={[
{ label: "Automatic", value: "auto" }, { label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" }, { label: "Manual", value: "manual" },
]} ]}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.autoDownloadUpdates}
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.checkNotifications}
title="Check for notifications"
help="Periodically ping Yaak servers to check for relevant notifications."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
<Checkbox
disabled
className="pl-2 mt-1 ml-[14rem]"
checked={false}
title="Send anonymous usage statistics"
help="Yaak is local-first and does not collect analytics or usage data 🔐"
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
</CargoFeature>
<Separator className="my-4" />
<Heading level={2}>
Workspace{" "}
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<PlainInput
required
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
labelClassName="w-[14rem]"
placeholder="0"
labelPosition="left"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => Number.parseInt(value, 10) >= 0}
onChange={(v) =>
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
}
type="number"
/>
<Checkbox
checked={workspace.settingValidateCertificates}
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
title="Validate TLS certificates"
onChange={(settingValidateCertificates) =>
patchModel(workspace, { settingValidateCertificates })
}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow redirects"
onChange={(settingFollowRedirects) =>
patchModel(workspace, {
settingFollowRedirects,
})
}
/>
</VStack>
<Separator className="my-4" />
<Heading level={2}>App Info</Heading>
<KeyValueRows>
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
<KeyValueRow
label="Data Directory"
rightSlot={
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appDataDir)}
/> />
}
> <ModelSettingRowBoolean
{appInfo.appDataDir} model={settings}
</KeyValueRow> modelKey="autoDownloadUpdates"
<KeyValueRow title="Automatically download updates"
label="Logs Directory" description="Download Yaak updates in the background so they are ready to install."
rightSlot={ disabled={!settings.autoupdate}
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appLogDir)}
/> />
}
> <ModelSettingRowBoolean
{appInfo.appLogDir} model={settings}
</KeyValueRow> modelKey="checkNotifications"
</KeyValueRows> title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="p-4 max-w-xl mx-auto"
>
<p>
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
</p>
</DismissibleBanner>
)}
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack> </VStack>
); );
} }
@@ -341,7 +341,7 @@ function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps
}} }}
className={classNames( className={classNames(
"flex items-center justify-center", "flex items-center justify-center",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full", "px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
"border-border-subtle focus:border-border-focus", "border-border-subtle focus:border-border-focus",
)} )}
> >
@@ -3,17 +3,27 @@ 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";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useState } from "react"; 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 { invokeCmd } from "../../lib/tauri"; import { invokeCmd } from "../../lib/tauri";
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";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { Select } from "../core/Select"; import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__"; const NULL_FONT_VALUE = "__NULL_FONT__";
@@ -38,154 +48,172 @@ export function SettingsInterface() {
} }
return ( return (
<VStack space={3} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-3"> <div className="mb-3">
<Heading>Interface</Heading> <Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p> <p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div> </div>
<Select <SettingsList className="space-y-8">
name="switchWorkspaceBehavior" <SettingsSection title="Workspaces">
label="Open workspace behavior" <SettingRowSelect
size="sm" title="Open workspace behavior"
help="When opening a workspace, should it open in the current window or a new window?" description="Choose what happens when opening another workspace."
value={ name="switchWorkspaceBehavior"
settings.openWorkspaceNewWindow === true value={
? "new" settings.openWorkspaceNewWindow === true
: settings.openWorkspaceNewWindow === false ? "new"
? "current" : settings.openWorkspaceNewWindow === false
: "ask" ? "current"
} : "ask"
onChange={async (v) => { }
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
/>
<HStack space={2} alignItems="end">
{fonts.data && (
<Select
size="sm"
name="uiFont"
label="Interface font"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...(fonts.data.uiFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
// Some people like monospace fonts for the UI
...(fonts.data.editorFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
]}
onChange={async (v) => { onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v; if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
await patchModel(settings, { interfaceFont }); else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}} }}
/>
)}
<Select
hideLabel
size="sm"
name="interfaceFontSize"
label="Interface Font Size"
defaultValue="14"
value={`${settings.interfaceFontSize}`}
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</HStack>
<HStack space={2} alignItems="end">
{fonts.data && (
<Select
size="sm"
name="editorFont"
label="Editor font"
value={settings.editorFont ?? NULL_FONT_VALUE}
options={[ options={[
{ label: "System default", value: NULL_FONT_VALUE }, { label: "Always ask", value: "ask" },
...(fonts.data.editorFonts.map((f) => ({ { label: "Open in current window", value: "current" },
label: f, { label: "Open in new window", value: "new" },
value: f,
})) ?? []),
]} ]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/> />
)} </SettingsSection>
<Select
hideLabel
size="sm"
name="editorFontSize"
label="Editor Font Size"
defaultValue="12"
value={`${settings.editorFontSize}`}
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
}
/>
</HStack>
<Select
leftSlot={<Icon icon="keyboard" color="secondary" />}
size="sm"
name="editorKeymap"
label="Editor keymap"
value={`${settings.editorKeymap}`}
options={keymaps}
onChange={(v) => patchModel(settings, { editorKeymap: v })}
/>
<Checkbox
checked={settings.editorSoftWrap}
title="Wrap editor lines"
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
<Checkbox
checked={settings.coloredMethods}
title="Colorize request methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
<NativeTitlebarSetting settings={settings} /> <SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
{type() !== "macos" && ( <SettingRow
<Checkbox title="Editor font"
checked={settings.hideWindowControls} description="Font used in request and response editors."
title="Hide window controls" controlClassName="gap-1"
help="Hide the close/maximize/minimize controls on Windows or Linux" >
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })} {fonts.data && (
/> <SettingSelectControl
)} name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack> </VStack>
); );
} }
function NativeTitlebarSetting({ settings }: { settings: Settings }) { function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar); const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return ( return (
<div className="flex gap-1 overflow-hidden h-2xs"> <SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox <Checkbox
hideLabel
size="md"
checked={nativeTitlebar} checked={nativeTitlebar}
title="Native title bar" title="Native title bar"
help="Use the operating system's standard title bar and window controls"
onChange={setNativeTitlebar} onChange={setNativeTitlebar}
/> />
{settings.useNativeTitlebar !== nativeTitlebar && ( {settings.useNativeTitlebar !== nativeTitlebar && (
<Button <Button
color="primary" color="primary"
size="2xs" size="xs"
onClick={async () => { onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar }); await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart"); await invokeCmd("cmd_restart");
@@ -194,7 +222,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
Apply and Restart Apply and Restart
</Button> </Button>
)} )}
</div> </SettingRow>
); );
} }
@@ -205,37 +233,42 @@ function LicenseSettings({ settings }: { settings: Settings }) {
} }
return ( return (
<Checkbox <SettingsSection title="License">
checked={settings.hideLicenseBadge} <SettingRowBoolean
title="Hide personal use badge" checked={settings.hideLicenseBadge}
onChange={async (hideLicenseBadge) => { title="Hide personal use badge"
if (hideLicenseBadge) { description="Hide the personal-use badge from the interface."
const confirmed = await showConfirm({ onChange={async (hideLicenseBadge) => {
id: "hide-license-badge", if (hideLicenseBadge) {
title: "Confirm Personal Use", const confirmed = await showConfirm({
confirmText: "Confirm", id: "hide-license-badge",
description: ( title: "Confirm Personal Use",
<VStack space={3}> confirmText: "Confirm",
<p>Hey there 👋🏼</p> description: (
<p> <VStack space={3}>
Yaak is free for personal projects and learning.{" "} <p>Hey there 👋🏼</p>
<strong>If youre using Yaak at work, a license is required.</strong> <p>
</p> Yaak is free for personal projects and learning.{" "}
<p> <strong>If youre using Yaak at work, a license is required.</strong>
Licenses help keep Yaak independent and sustainable.{" "} </p>
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link> <p>
</p> Licenses help keep Yaak independent and sustainable.{" "}
</VStack> <Link href={pricingUrl("app.license.badge-hide-confirm")}>
), Purchase a License
requireTyping: "Personal Use", </Link>
color: "info", </p>
}); </VStack>
if (!confirmed) { ),
return; // Cancel requireTyping: "Personal Use",
color: "info",
});
if (!confirmed) {
return;
}
} }
} await patchModel(settings, { hideLicenseBadge });
await patchModel(settings, { hideLicenseBadge }); }}
}} />
/> </SettingsSection>
); );
} }
@@ -6,6 +6,7 @@ import { formatDate } from "date-fns/format";
import { useState } from "react"; import { useState } from "react";
import { useToggle } from "../../hooks/useToggle"; import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize"; import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span> <span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")} onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl( openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -211,7 +211,7 @@ function PluginTableRow({
return ( return (
<TableRow> <TableRow>
{showCheckbox && ( {showCheckbox && (
<TableCell className="!py-0"> <TableCell className="py-0!">
<Checkbox <Checkbox
hideLabel hideLabel
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"} title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
@@ -249,7 +249,7 @@ function PluginTableRow({
)} )}
</HStack> </HStack>
</TableCell> </TableCell>
<TableCell className="!py-0"> <TableCell className="py-0!">
<HStack justifyContent="end" space={1.5}> <HStack justifyContent="end" space={1.5}>
{plugin != null && latestVersion != null ? ( {plugin != null && latestVersion != null ? (
<Button <Button
@@ -1,13 +1,29 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui"; import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Checkbox } from "../core/Checkbox"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { PlainInput } from "../core/PlainInput"; import {
import { Select } from "../core/Select"; SettingRowBoolean,
import { Separator } from "../core/Separator"; SettingRowSelect,
SettingRowText,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsProxy() { export function SettingsProxy() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const proxy = enabledProxyOrDefault(settings.proxy);
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
await patchModel(settings, {
proxy: {
...proxy,
...patch,
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
},
});
};
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
@@ -18,188 +34,147 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<Select <CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
name="proxy" <SettingsList className="space-y-8">
label="Proxy" <SettingsSection title="Proxy">
hideLabel <SettingRowSelect
size="sm" title="Proxy"
value={settings.proxy?.type ?? "automatic"} description="Choose how Yaak should discover or use proxy settings."
onChange={async (v) => { name="proxy"
if (v === "automatic") { value={settings.proxy?.type ?? "automatic"}
await patchModel(settings, { proxy: undefined }); onChange={async (v) => {
} else if (v === "enabled") { if (v === "automatic") {
await patchModel(settings, { await patchModel(settings, { proxy: undefined });
proxy: { } else if (v === "enabled") {
disabled: false, await patchModel(settings, { proxy });
type: "enabled", } else {
http: "", await patchModel(settings, { proxy: { type: "disabled" } });
https: "",
auth: { user: "", password: "" },
bypass: "",
},
});
} else {
await patchModel(settings, { proxy: { type: "disabled" } });
}
}}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
/>
{settings.proxy?.type === "enabled" && (
<VStack space={1.5}>
<Checkbox
className="my-3"
checked={!settings.proxy.disabled}
title="Enable proxy"
help="Use this to temporarily disable the proxy without losing the configuration"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = !enabled;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
<HStack space={1.5}>
<PlainInput
size="sm"
label={
<>
Proxy for <InlineCode>http://</InlineCode> traffic
</>
} }
placeholder="localhost:9090"
defaultValue={settings.proxy?.http}
onChange={async (http) => {
const { proxy } = settings;
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: {
type: "enabled",
http,
https,
auth,
disabled,
bypass,
},
});
}}
/>
<PlainInput
size="sm"
label={
<>
Proxy for <InlineCode>https://</InlineCode> traffic
</>
}
placeholder="localhost:9090"
defaultValue={settings.proxy?.https}
onChange={async (https) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
</HStack>
<Separator className="my-6" />
<Checkbox
checked={settings.proxy.auth != null}
title="Enable authentication"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = enabled ? { user: "", password: "" } : null;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}} }}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
selectClassName="w-64!"
/> />
</SettingsSection>
{settings.proxy.auth != null && ( {settings.proxy?.type === "enabled" && (
<HStack space={1.5}> <>
<PlainInput <SettingsSection title="Custom Proxy">
required <SettingRowBoolean
size="sm" checked={!settings.proxy.disabled}
label="User" title="Enable proxy"
placeholder="myUser" description="Temporarily disable the proxy without losing the configuration."
defaultValue={settings.proxy.auth.user} onChange={(enabled) => patchProxy({ disabled: !enabled })}
onChange={async (user) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
<PlainInput <SettingRowText
size="sm" name="proxyHttp"
label="Password" title={
type="password" <>
placeholder="s3cretPassw0rd" Proxy for <InlineCode>http://</InlineCode> traffic
defaultValue={settings.proxy.auth.password} </>
onChange={async (password) => { }
const { proxy } = settings; description="Proxy host used for unencrypted HTTP traffic."
const http = proxy?.type === "enabled" ? proxy.http : ""; value={settings.proxy.http}
const https = proxy?.type === "enabled" ? proxy.https : ""; placeholder="localhost:9090"
const disabled = proxy?.type === "enabled" ? proxy.disabled : false; onChange={(http) => patchProxy({ http })}
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
</HStack> <SettingRowText
)} name="proxyHttps"
{settings.proxy.type === "enabled" && ( title={
<> <>
<Separator className="my-6" /> Proxy for <InlineCode>https://</InlineCode> traffic
<PlainInput </>
label="Proxy Bypass" }
help="Comma-separated list to bypass the proxy." description="Proxy host used for HTTPS traffic."
defaultValue={settings.proxy.bypass} value={settings.proxy.https}
placeholder="localhost:9090"
onChange={(https) => patchProxy({ https })}
/>
<SettingRowText
name="proxyBypass"
title="Proxy Bypass"
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000" placeholder="127.0.0.1, *.example.com, localhost:3000"
onChange={async (bypass) => { inputWidthClassName="w-96!"
const { proxy } = settings; onChange={(bypass) => patchProxy({ bypass })}
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
</> </SettingsSection>
)}
</VStack> <SettingsSection title="Authentication">
)} <SettingRowBoolean
checked={settings.proxy.auth != null}
title="Enable authentication"
description="Send proxy credentials with proxied requests."
onChange={(enabled) =>
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
}
/>
{settings.proxy.auth != null && (
<>
<SettingRowText
required
name="proxyUser"
title="User"
description="Username for proxy authentication."
value={settings.proxy.auth.user}
placeholder="myUser"
onChange={(user) =>
patchProxy({
auth: {
user,
password:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.password ?? "")
: "",
},
})
}
/>
<SettingRowText
name="proxyPassword"
title="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password"
onChange={(password) =>
patchProxy({
auth: {
user:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.user ?? "")
: "",
password,
},
})
}
/>
</>
)}
</SettingsSection>
</>
)}
</SettingsList>
</VStack> </VStack>
); );
} }
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
if (proxy?.type === "enabled") return proxy;
return {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
};
}
@@ -9,7 +9,12 @@ import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select"; import type { SelectProps } from "../core/Select";
import { Select } from "../core/Select"; import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor }))); const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
@@ -67,7 +72,7 @@ export function SettingsTheme() {
})); }));
return ( return (
<VStack space={3} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-3"> <div className="mb-3">
<Heading>Theme</Heading> <Heading>Theme</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">
@@ -77,96 +82,92 @@ export function SettingsTheme() {
</Link> </Link>
</p> </p>
</div> </div>
<Select <SettingsList className="space-y-8">
name="appearance" <SettingsSection title="Theme">
label="Appearance" <ModelSettingRowSelect
labelPosition="top" model={settings}
size="sm" modelKey="appearance"
value={settings.appearance} title="Appearance"
onChange={(appearance) => patchModel(settings, { appearance })} description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[ options={[
{ label: "Automatic", value: "system" }, { label: "Automatic", value: "system" },
{ label: "Light", value: "light" }, { label: "Light", value: "light" },
{ label: "Dark", value: "dark" }, { label: "Dark", value: "dark" },
]} ]}
/>
<HStack space={2}>
{(settings.appearance === "system" || settings.appearance === "light") && (
<Select
hideLabel
leftSlot={<Icon icon="sun" color="secondary" />}
name="lightTheme"
label="Light Theme"
size="sm"
className="flex-1"
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/> />
)} {(settings.appearance === "system" || settings.appearance === "light") && (
{(settings.appearance === "system" || settings.appearance === "dark") && ( <SettingRowSelect
<Select name="lightTheme"
hideLabel title="Light theme"
name="darkTheme" description="Theme used when Yaak is in light mode."
className="flex-1" value={activeTheme.data.light.id}
label="Dark Theme" options={lightThemes}
leftSlot={<Icon icon="moon" color="secondary" />} onChange={(themeLight) => patchModel(settings, { themeLight })}
size="sm" />
value={activeTheme.data.dark.id} )}
options={darkThemes} {(settings.appearance === "system" || settings.appearance === "dark") && (
onChange={(themeDark) => patchModel(settings, { themeDark })} <SettingRowSelect
/> name="darkTheme"
)} title="Dark theme"
</HStack> description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<VStack <SettingsSection title="Preview">
space={3} <VStack
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto" space={3}
> className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
<HStack className="text" space={1.5}> >
<Icon icon={appearance === "dark" ? "moon" : "sun"} /> <HStack className="text" space={1.5}>
<strong>{activeTheme.data.active.label}</strong> <Icon icon={appearance === "dark" ? "moon" : "sun"} />
<em>(preview)</em> <strong>{activeTheme.data.active.label}</strong>
</HStack> <em>(preview)</em>
<HStack space={1.5} className="w-full"> </HStack>
{buttonColors.map((c, i) => ( <HStack space={1.5} className="w-full">
<IconButton {buttonColors.map((c, i) => (
key={c} <IconButton
color={c} key={c}
size="2xs" color={c}
iconSize="xs" size="2xs"
icon={icons[i % icons.length] ?? "info"} iconSize="xs"
iconClassName="text" icon={icons[i % icons.length] ?? "info"}
title={`${c}`} iconClassName="text"
/> title={`${c}`}
))} />
{buttonColors.map((c, i) => ( ))}
<IconButton {buttonColors.map((c, i) => (
key={c} <IconButton
color={c} key={c}
variant="border" color={c}
size="2xs" variant="border"
iconSize="xs" size="2xs"
icon={icons[i % icons.length] ?? "info"} iconSize="xs"
iconClassName="text" icon={icons[i % icons.length] ?? "info"}
title={`${c}`} iconClassName="text"
/> title={`${c}`}
))} />
</HStack> ))}
<Suspense> </HStack>
<Editor <Suspense>
defaultValue={[ <Editor
"let foo = { // Demo code editor", defaultValue={[
' foo: ("bar" || "baz" ?? \'qux\'),', "let foo = { // Demo code editor",
" baz: [1, 10.2, null, false, true],", ' foo: ("bar" || "baz" ?? \'qux\'),',
"};", " baz: [1, 10.2, null, false, true],",
].join("\n")} "};",
heightMode="auto" ].join("\n")}
language="javascript" heightMode="auto"
stateKey={null} language="javascript"
/> stateKey={null}
</Suspense> />
</VStack> </Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack> </VStack>
); );
} }
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData"; import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown"; import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui"; import { Icon } from "@yaakapp-internal/ui";
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active", hidden: check.data == null || check.data.status === "active",
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: () => openUrl("https://yaak.app/pricing"), onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
+113 -7
View File
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown"; import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension"; import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension"; import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query"; import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag"; import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { import {
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import type { InputHandle } from "./core/Input"; import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input"; import { Input } from "./core/Input";
import { EmptyStateText } from "./EmptyStateText";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage"; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { GitDropdown } from "./git/GitDropdown"; import { GitDropdown } from "./git/GitDropdown";
import { gitCallbacks } from "./git/callbacks"; import { gitCallbacks } from "./git/callbacks";
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom); const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields] = 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);
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
); );
const clearFilterText = useCallback(() => { const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` }); setSidebarFilterText("");
requestAnimationFrame(() => { requestAnimationFrame(() => {
filterRef.current?.focus(); filterRef.current?.focus();
}); });
@@ -252,6 +255,13 @@ function Sidebar({ className }: { className?: string }) {
[], [],
); );
const applyFilterExample = useCallback((text: string) => {
setSidebarFilterText(text);
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback( const getSelectedTreeModels = useCallback(
@@ -578,7 +588,7 @@ function Sidebar({ className }: { className?: string }) {
rightSlot={ rightSlot={
filterText.text && ( filterText.text && (
<IconButton <IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1" className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x" icon="x"
title="Clear filter" title="Clear filter"
onClick={clearFilterText} onClick={clearFilterText}
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
)} )}
</div> </div>
{allHidden ? ( {allHidden ? (
<div className="italic text-text-subtle p-3 text-sm text-center"> <div className="p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode> {(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="h-auto! mb-auto"
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
>
<div>
No results, but found matches for{" "}
{emptyFilterSuggestions?.map((suggestion, i) => (
<span key={suggestion.field}>
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded-sm align-middle focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{suggestion.filterText}
</InlineCode>
</button>
</span>
))}
</div>
</EmptyStateText>
) : (
<EmptyStateText
wrapperClassName="h-auto! mb-auto"
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
>
<div>
No results for{" "}
<InlineCode className="inline-block max-w-36 truncate align-middle">
{filterText.text}
</InlineCode>
</div>
</EmptyStateText>
)}
</div> </div>
) : ( ) : (
<Tree <Tree
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "", key: "",
}); });
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => { type SidebarFilterSuggestion = {
field: string;
filterText: string;
};
function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
return null;
}
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
}
const sidebarSuggestionFieldOrder = [
"url",
"folder",
"method",
"type",
"grpc_service",
"grpc_method",
"name",
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom); const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom); const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom); const filter = get(sidebarFilterAtom);
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
} }
const queryAst = parseQuery(filter.text); const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter // returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {}; const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => { const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? []; const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true; let matchesSelf = true;
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
if (!value) continue; if (!value) continue;
allFields[field] = allFields[field] ?? new Set(); allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value); allFields[field].add(value);
if (
isLeafNode &&
suggestionValue != null &&
sidebarFieldMatchesValue(value, suggestionValue)
) {
suggestionFields.add(field);
}
} }
if (queryAst != null) { if (queryAst != null) {
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
values: Array.from(values).filter((v) => v.length < 20), values: Array.from(values).filter((v) => v.length < 20),
}); });
} }
return [root, fields] as const; const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
})
.map((field) => ({
field,
filterText: formatFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
}); });
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => { const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -4,20 +4,79 @@ import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
export interface SyncToFilesystemSettingProps { export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void; onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void; onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean }; value: { filePath: string | null; initGit?: boolean };
} }
export function SyncToFilesystemSetting({ export function SyncToFilesystemSetting({
layout = "form",
onChange, onChange,
onCreateNewWorkspace, onCreateNewWorkspace,
value, value,
}: SyncToFilesystemSettingProps) { }: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null); const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return ( return (
<VStack className="w-full my-2" space={3}> <VStack className="w-full my-2" space={3}>
{syncDir && ( {syncDir && (
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
noun="Directory" noun="Directory"
help="Sync data to a folder for backup and Git integration." help="Sync data to a folder for backup and Git integration."
filePath={value.filePath} filePath={value.filePath}
onChange={async ({ filePath }) => { onChange={async ({ filePath }) => handleFilePathChange(filePath)}
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
}}
/> />
{value.filePath && typeof value.initGit === "boolean" && ( {value.filePath && typeof value.initGit === "boolean" && (
@@ -208,10 +208,10 @@ function InitializedTemplateFunctionDialog({
)} )}
/> />
</HStack> </HStack>
<div className="relative w-full max-h-[10rem]"> <div className="relative w-full max-h-40">
<InlineCode <InlineCode
className={classNames( className={classNames(
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest", "block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger", tooLarge && "italic text-danger",
)} )}
> >
@@ -246,7 +246,7 @@ function InitializedTemplateFunctionDialog({
) : ( ) : (
<span /> <span />
)} )}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1"> <div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && ( {templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}> <Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key Reveal Encryption Key
@@ -271,7 +271,7 @@ TemplateFunctionDialog.show = (
showDialog({ showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md", size: "md",
className: "h-[60rem]", className: "h-240",
noPadding: true, noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>, title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description, description: fn.description,
+1 -1
View File
@@ -94,7 +94,7 @@ export const UrlBar = memo(function UrlBar({
iconSize="md" iconSize="md"
title="Send Request" title="Send Request"
type="submit" type="submit"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 h-full!"
iconColor="secondary" iconColor="secondary"
icon={isLoading ? "x" : submitIcon} icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send" hotkeyAction="request.send"
@@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms"; 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 { 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";
@@ -34,6 +35,7 @@ import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -48,6 +50,7 @@ const TAB_MESSAGE = "message";
const TAB_PARAMS = "params"; const TAB_PARAMS = "params";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
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";
@@ -69,6 +72,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL) // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent( useRequestEditorEvent(
@@ -80,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -109,12 +111,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
}, },
]; ];
}, [authTab, headersTab, urlParameterPairs.length]); }, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -210,7 +217,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
title="Close connection" title="Close connection"
icon="x" icon="x"
iconColor="secondary" iconColor="secondary"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 h-full!"
onClick={handleCancel} onClick={handleCancel}
/> />
) )
@@ -229,7 +236,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
ref={tabsRef} ref={tabsRef}
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 mb-1.5!"
storageKey={TABS_STORAGE_KEY} storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId} activeTabKey={activeRequestId}
> >
@@ -266,6 +273,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`json.${activeRequest.id}`} stateKey={`json.${activeRequest.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}> <TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput <PlainInput
@@ -273,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
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) => patchModel(activeRequest, { name })}
@@ -105,10 +105,18 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary"; messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "close" || messageType === "open" messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -119,6 +127,8 @@ function WebsocketEventRow({
"Disconnected from server" "Disconnected from server"
) : messageType === "open" ? ( ) : messageType === "open" ? (
"Connected to server" "Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? ( ) : message === "" ? (
<em className="italic text-text-subtlest">No content</em> <em className="italic text-text-subtlest">No content</em>
) : ( ) : (
@@ -170,7 +180,9 @@ function WebsocketEventDetail({
? "Connection Closed" ? "Connection Closed"
: event.messageType === "open" : event.messageType === "open"
? "Connection Open" ? "Connection Open"
: `Message ${event.isServer ? "Received" : "Sent"}`; : event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] = const actions: EventDetailAction[] =
message !== "" message !== ""
+8 -2
View File
@@ -6,6 +6,7 @@ import { useAtomValue } from "jotai";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
getActiveCookieJar,
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId, useSubscribeActiveCookieJarId,
} from "../hooks/useActiveCookieJar"; } from "../hooks/useActiveCookieJar";
@@ -33,6 +34,7 @@ import { jotaiStore } from "../lib/jotai";
import { CreateDropdown } from "./CreateDropdown"; import { CreateDropdown } from "./CreateDropdown";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList"; import { HotkeyList } from "./core/HotkeyList";
import { CookieDialog } from "./CookieDialog";
import { FeedbackLink } from "./core/Link"; import { FeedbackLink } from "./core/Link";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout"; import { FolderLayout } from "./FolderLayout";
@@ -83,7 +85,7 @@ export function Workspace() {
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" /> <div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div <div
style={environmentBgStyle} style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20" className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/> />
</div> </div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} /> <WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
@@ -160,7 +162,7 @@ function WorkspaceBody() {
// Delay the entering because the workspaces might load after a slight delay // Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
<Banner color="warning" className="max-w-[30rem]"> <Banner color="warning" className="max-w-120">
The active workspace was not found. Select a workspace from the header menu or report this The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink /> bug to <FeedbackLink />
</Banner> </Banner>
@@ -218,4 +220,8 @@ function useGlobalWorkspaceHooks() {
useHotKey("model.duplicate", () => useHotKey("model.duplicate", () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)), duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
); );
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
enable: () => getActiveCookieJar() != null,
});
} }
@@ -176,7 +176,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text !px-2 truncate", "text px-2! truncate",
workspace === null && "italic opacity-disabled", workspace === null && "italic opacity-disabled",
)} )}
{...buttonProps} {...buttonProps}
@@ -20,16 +20,24 @@ import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip"; import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label"; import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp"; import { EncryptionHelp } from "./EncryptionHelp";
interface Props { interface Props {
layout?: "form" | "settings";
size?: ButtonProps["size"]; size?: ButtonProps["size"];
expanded?: boolean; expanded?: boolean;
onDone?: () => void; onDone?: () => void;
onEnabledEncryption?: () => void; onEnabledEncryption?: () => void;
} }
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) { export function WorkspaceEncryptionSetting({
layout = "form",
size,
expanded,
onDone,
onEnabledEncryption,
}: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false); const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
key.error != null || key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) { ) {
return ( const enterKey = (
<EnterWorkspaceKey <EnterWorkspaceKey
workspaceMeta={workspaceMeta} workspaceMeta={workspaceMeta}
error={key.error} error={key.error}
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
}} }}
/> />
); );
return enterKey;
} }
// Show the key if it exists // Show the key if it exists
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
encryptionKey={key.key} encryptionKey={key.key}
/> />
); );
return (
const content = (
<VStack space={2} className="w-full"> <VStack space={2} className="w-full">
{justEnabledEncryption && ( {justEnabledEncryption && (
<Banner color="success" className="flex flex-col gap-2"> <Banner color="success" className="flex flex-col gap-2">
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
)} )}
</VStack> </VStack>
); );
return content;
} }
// Show button to enable encryption // Show button to enable encryption
if (layout === "settings") {
return (
<>
{error && (
<Banner color="danger" className="mb-3">
{error}
</Banner>
)}
<SettingRow
title="Workspace encryption"
description="Encrypt workspace secrets and sensitive values at rest."
>
<Button
color="secondary"
size={size}
onClick={async () => {
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError(`Failed to enable encryption: ${String(err)}`);
}
}}
>
Enable Encryption
</Button>
</SettingRow>
</>
);
}
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
@@ -279,7 +324,7 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) { function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return ( return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text"> <span className="text-xs font-mono **:cursor-auto **:select-text">
{show ? ( {show ? (
keyText.split("").map((c, i) => { keyText.split("").map((c, i) => {
return ( return (
@@ -1,20 +1,23 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { CopyIconButton } from "./CopyIconButton"; import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs"; import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { DnsOverridesEditor } from "./DnsOverridesEditor"; import { DnsOverridesEditor } from "./DnsOverridesEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { ModelSettingsEditor } from "./ModelSettingsEditor";
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting"; import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting"; import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
@@ -25,17 +28,17 @@ interface Props {
} }
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_DATA = "data";
const TAB_DNS = "dns"; const TAB_DNS = "dns";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_GENERAL = "general"; const TAB_GENERAL = "general";
const TAB_SETTINGS = "settings";
export type WorkspaceSettingsTab = export type WorkspaceSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
| typeof TAB_DNS | typeof TAB_DNS
| typeof TAB_HEADERS | typeof TAB_HEADERS
| typeof TAB_GENERAL | typeof TAB_GENERAL
| typeof TAB_DATA; | typeof TAB_SETTINGS;
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL; const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
tabs={[ tabs={[
{ value: TAB_GENERAL, label: "Workspace" }, { value: TAB_GENERAL, label: "Workspace" },
{ {
value: TAB_DATA, value: TAB_SETTINGS,
label: "Storage", label: "Settings",
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
@@ -100,6 +103,22 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
stateKey={`headers.${workspace.id}`} stateKey={`headers.${workspace.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
<PlainInput <PlainInput
@@ -108,7 +127,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
placeholder="Workspace Name" placeholder="Workspace Name"
label="Name" label="Name"
defaultValue={workspace.name} defaultValue={workspace.name}
className="!text-base font-sans" className="text-base! font-sans"
onChange={(name) => patchModel(workspace, { name })} onChange={(name) => patchModel(workspace, { name })}
/> />
@@ -142,7 +161,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{workspaceId} {workspaceId}
<CopyIconButton <CopyIconButton
className="opacity-70 !text-primary" className="opacity-70 text-primary!"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy workspace ID" title="Copy workspace ID"
@@ -152,19 +171,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
</HStack> </HStack>
</div> </div>
</TabContent> </TabContent>
<TabContent value={TAB_DATA} className="overflow-y-auto h-full px-4">
<VStack space={4} alignItems="start" className="pb-3 h-full">
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<WorkspaceEncryptionSetting size="xs" />
</VStack>
</TabContent>
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
<DnsOverridesEditor workspace={workspace} /> <DnsOverridesEditor workspace={workspace} />
</TabContent> </TabContent>
</Tabs> </Tabs>
); );
} }
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({
id: "workspace-settings",
size: "lg",
className: "h-[calc(100vh-5rem)] max-h-200!",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
};
@@ -72,7 +72,7 @@ export function AutoScroller<T>({
size="sm" size="sm"
iconSize="md" iconSize="md"
variant="border" variant="border"
className="!bg-surface z-10" className="bg-surface! z-10"
onClick={() => setAutoScroll((v) => !v)} onClick={() => setAutoScroll((v) => !v)}
/> />
</div> </div>
@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full overflow-y-auto focus:outline-none" className="h-full w-full overflow-y-auto focus:outline-hidden"
onScroll={handleScroll} onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined} tabIndex={focusable ? 0 : undefined}
> >
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vite-plus/test";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
expect(parseBulkPairLine("foo: bar")).toMatchObject({
enabled: true,
name: "foo",
value: "bar",
});
});
test("preserves colon-without-space lines as a name with an empty value", () => {
expect(parseBulkPairLine("foo:bar")).toMatchObject({
enabled: true,
name: "foo:bar",
value: "",
});
});
test("preserves malformed lines instead of dropping their contents", () => {
expect(parseBulkPairLine("not a pair")).toMatchObject({
enabled: true,
name: "not a pair",
value: "",
});
});
test("unescapes newlines in parsed values", () => {
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
enabled: true,
name: "foo",
value: "bar\nbaz",
});
});
});
@@ -17,7 +17,7 @@ export function BulkPairEditor({
const pairsText = useMemo(() => { const pairsText = useMemo(() => {
return pairs return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === "")) .filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(pairToLine) .map(formatBulkPairLine)
.join("\n"); .join("\n");
}, [pairs]); }, [pairs]);
@@ -26,7 +26,7 @@ export function BulkPairEditor({
const pairs = text const pairs = text
.split("\n") .split("\n")
.filter((l: string) => l.trim()) .filter((l: string) => l.trim())
.map(lineToPair); .map(parseBulkPairLine);
onChange(pairs); onChange(pairs);
}, },
[onChange], [onChange],
@@ -47,16 +47,16 @@ export function BulkPairEditor({
); );
} }
function pairToLine(pair: Pair) { export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n"); const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`; return `${pair.name}: ${value}`;
} }
function lineToPair(line: string): PairWithId { export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? []; const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return { return {
enabled: true, enabled: true,
name: (name ?? "").trim(), name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(), value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(), id: generateId(),
}; };
@@ -13,6 +13,7 @@ export interface CheckboxProps {
hideLabel?: boolean; hideLabel?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
help?: ReactNode; help?: ReactNode;
size?: "sm" | "md";
} }
export function Checkbox({ export function Checkbox({
@@ -25,6 +26,7 @@ export function Checkbox({
hideLabel, hideLabel,
fullWidth, fullWidth,
help, help,
size = "sm",
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
<HStack <HStack
@@ -37,8 +39,10 @@ export function Checkbox({
<input <input
aria-hidden aria-hidden
className={classNames( className={classNames(
"appearance-none w-4 h-4 flex-shrink-0 border border-border", "appearance-none shrink-0 border border-border",
"rounded outline-none ring-0", size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]", !disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted", disabled && "border-dotted",
)} )}
@@ -50,7 +54,7 @@ export function Checkbox({
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Icon <Icon
size="sm" size={size}
className={classNames(disabled && "opacity-disabled")} className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"} icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/> />
@@ -17,7 +17,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
<div className={className}> <div className={className}>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="!w-full" className="w-full!"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -96,7 +96,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
<> <>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="!w-full" className="w-full!"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -18,7 +18,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
className={classNames( className={classNames(
className, className,
"flex items-center", "flex items-center",
"opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono", "opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
color == null && "border-border-subtle", color == null && "border-border-subtle",
color === "primary" && "text-primary", color === "primary" && "text-primary",
color === "secondary" && "text-secondary", color === "secondary" && "text-secondary",
@@ -42,7 +42,7 @@ export function DetailsBanner({
return ( return (
<Banner color={color} className={className}> <Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}> <details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70"> <summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div <div
className={classNames( className={classNames(
"transition-transform", "transition-transform",
+6 -6
View File
@@ -74,13 +74,13 @@ export function Dialog({
"relative bg-surface pointer-events-auto", "relative bg-surface pointer-events-auto",
"rounded-lg", "rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]", "border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-[10rem]", "min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]", "max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-[30rem]", size === "sm" && "w-120",
size === "md" && "w-[50rem]", size === "md" && "w-200",
size === "lg" && "w-[70rem]", size === "lg" && "w-280",
size === "full" && "w-[100vw] h-[100vh]", size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-[20rem] max-w-[100vw]", size === "dynamic" && "min-w-80 max-w-[100vw]",
)} )}
> >
{title ? ( {title ? (
@@ -1,57 +1,111 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner, HStack } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { MouseEvent } from "react";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
type DismissibleBannerSize = "sm" | "xs";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
size = "sm",
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
actions?: { label: string; onClick: () => void; color?: Color }[]; size?: DismissibleBannerSize;
onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
}) { }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({ const {
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
if (dismissed) return null; const shouldShow = !isLoading && !dismissed;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
const stopParentClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<Banner <Banner
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")} className={classNames(
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
{...props} {...props}
> >
{children} <div className="@container">
<HStack space={1.5}> <div
{actions?.map((a) => ( className={classNames(
<Button "grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
key={a.label} size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
variant="border" )}
color={a.color ?? props.color}
size="xs"
onClick={a.onClick}
title={a.label}
>
{a.label}
</Button>
))}
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
> >
Dismiss {children}
</Button> <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
</HStack> <Button
variant="border"
color={props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
}}
title="Dismiss message"
>
Dismiss
</Button>
{actions?.map((a) => (
<Button
key={a.label}
variant={a.variant ?? "border"}
color={a.color ?? props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label}
>
{a.label}
</Button>
))}
</div>
</div>
</div>
</Banner> </Banner>
); );
} }
+13 -13
View File
@@ -712,7 +712,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
className={classNames( className={classNames(
className, className,
"x-theme-menu", "x-theme-menu",
"outline-none my-1 pointer-events-auto z-40", "outline-hidden my-1 pointer-events-auto z-40",
"fixed", "fixed",
)} )}
> >
@@ -734,7 +734,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
{filter && ( {filter && (
<HStack <HStack
space={2} space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs" className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded-sm font-mono h-xs"
> >
<Icon icon="search" size="xs" /> <Icon icon="search" size="xs" />
<div className="text">{filter}</div> <div className="text">{filter}</div>
@@ -916,24 +916,24 @@ function MenuItem({
) )
} }
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left" innerClassName="text-left!"
color="custom" color="custom"
className={classNames( className={classNames(
className, className,
"h-xs", // More compact "h-xs", // More compact
"min-w-[8rem] outline-none 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 focus:outline-none 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", isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
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!",
item.color === "warning" && "!text-warning", item.color === "warning" && "text-warning!",
item.color === "notice" && "!text-notice", item.color === "notice" && "text-notice!",
item.color === "info" && "!text-info", item.color === "info" && "text-info!",
)} )}
{...props} {...props}
> >
<div className={classNames("truncate min-w-[5rem]")}>{item.label}</div> <div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button> </Button>
); );
} }
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper.cm-multiline .cm-mergeView { .cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5; @apply h-full w-full overflow-auto pr-0.5;
@@ -9,7 +11,7 @@
@apply w-full min-h-full relative; @apply w-full min-h-full relative;
.cm-collapsedLines { .cm-collapsedLines {
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default; @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
} }
} }
@@ -19,21 +21,21 @@
.cm-changedLine { .cm-changedLine {
/* Round top corners only if previous line is not a changed line */ /* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) { &:not(.cm-changedLine + &) {
@apply rounded-t; @apply rounded-t-sm;
} }
/* Round bottom corners only if next line is not a changed line */ /* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) { &:not(:has(+ .cm-changedLine)) {
@apply rounded-b; @apply rounded-b-sm;
} }
} }
/* Let content grow and disable individual scrolling for sync */ /* Let content grow and disable individual scrolling for sync */
.cm-editor { .cm-editor {
@apply h-auto relative !important; @apply h-auto! relative!;
position: relative !important; position: relative !important;
} }
.cm-scroller { .cm-scroller {
@apply overflow-visible !important; @apply overflow-visible!;
} }
} }
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper { .cm-wrapper {
@apply h-full overflow-hidden; @apply h-full overflow-hidden;
@@ -7,7 +9,7 @@
/* Regular cursor */ /* Regular cursor */
.cm-cursor { .cm-cursor {
@apply border-text !important; @apply border-text!;
/* Widen the cursor a bit */ /* Widen the cursor a bit */
@apply border-l-[2px]; @apply border-l-[2px];
} }
@@ -15,8 +17,8 @@
/* Vim-mode cursor */ /* Vim-mode cursor */
.cm-fat-cursor { .cm-fat-cursor {
@apply outline-0 bg-text !important; @apply outline-0! bg-text!;
@apply text-surface !important; @apply text-surface!;
} }
/* Matching bracket */ /* Matching bracket */
@@ -59,12 +61,12 @@
* { * {
@apply cursor-text; @apply cursor-text;
@apply caret-transparent !important; @apply caret-transparent!;
} }
} }
.cm-selectionBackground { .cm-selectionBackground {
@apply bg-selection !important; @apply bg-selection!;
} }
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost /* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
@@ -88,7 +90,7 @@
} }
.cm-gutter-lint { .cm-gutter-lint {
@apply w-auto !important; @apply w-auto!;
.cm-gutterElement { .cm-gutterElement {
@apply px-0; @apply px-0;
@@ -111,7 +113,7 @@
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default; @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border hover:text-text hover:bg-surface-highlight; @apply hover:border-border hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow; @apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
-webkit-text-security: none; -webkit-text-security: none;
@@ -162,7 +164,7 @@
&::-webkit-scrollbar-corner, &::-webkit-scrollbar-corner,
&::-webkit-scrollbar { &::-webkit-scrollbar {
@apply hidden !important; @apply hidden!;
} }
} }
} }
@@ -189,16 +191,16 @@
/* Style search matches */ /* Style search matches */
.cm-searchMatch { .cm-searchMatch {
@apply bg-transparent !important; @apply bg-transparent!;
@apply rounded-[2px] outline outline-1; @apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected { &.cm-searchMatch-selected {
@apply outline-text; @apply outline-text;
@apply bg-text !important; @apply bg-text!;
&, &,
* { * {
@apply text-surface font-semibold !important; @apply text-surface! font-semibold!;
} }
} }
} }
@@ -223,8 +225,8 @@
} }
.cm-editor .fold-gutter-icon { .cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded; @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
@apply cursor-default !important; @apply cursor-default!;
} }
.cm-editor .fold-gutter-icon::after { .cm-editor .fold-gutter-icon::after {
@@ -248,7 +250,7 @@
.cm-editor .cm-foldPlaceholder { .cm-editor .cm-foldPlaceholder {
@apply px-2 border border-border-subtle bg-surface-highlight; @apply px-2 border border-border-subtle bg-surface-highlight;
@apply hover:text-text hover:border-border-subtle text-text; @apply hover:text-text hover:border-border-subtle text-text;
@apply cursor-default !important; @apply cursor-default!;
} }
.cm-editor .cm-activeLineGutter { .cm-editor .cm-activeLineGutter {
@@ -277,7 +279,7 @@
} }
.cm-tooltip-lint { .cm-tooltip-lint {
@apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important; @apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
.cm-diagnostic-error { .cm-diagnostic-error {
@apply border-l-danger px-4 py-2; @apply border-l-danger px-4 py-2;
@@ -293,18 +295,18 @@
} }
.cm-tooltip.cm-tooltip-hover { .cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm; @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
@apply p-1.5; @apply p-1.5;
/* Style the tooltip for popping up "open in browser" and other stuff */ /* Style the tooltip for popping up "open in browser" and other stuff */
a, a,
button { button {
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded; @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
} }
a { a {
@apply cursor-default !important; @apply cursor-default!;
&::after { &::after {
@apply text-text bg-text h-3 w-3 ml-1; @apply text-text bg-text h-3 w-3 ml-1;
@@ -319,10 +321,10 @@
/* NOTE: Extra selector required to override default styles */ /* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete, .cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo { .cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto; @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
& * { & * {
@apply font-mono text-editor !important; @apply font-mono! text-editor!;
} }
.cm-completionIcon { .cm-completionIcon {
@@ -409,7 +411,7 @@
} }
.cm-completionIcon { .cm-completionIcon {
@apply text-sm flex items-center pb-0.5 flex-shrink-0; @apply text-sm flex items-center pb-0.5 shrink-0;
} }
.cm-completionLabel { .cm-completionLabel {
@@ -427,7 +429,7 @@
input, input,
button { button {
@apply rounded-sm outline-none; @apply rounded-sm outline-hidden;
} }
button { button {
@@ -436,12 +438,12 @@
} }
button[name="close"] { button[name="close"] {
@apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important; @apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
} }
input { input {
@apply bg-surface border-border-subtle focus:border-border-focus; @apply bg-surface border-border-subtle focus:border-border-focus;
@apply border outline-none; @apply border outline-hidden;
} }
input.cm-textfield { input.cm-textfield {
@@ -282,6 +282,22 @@ function EditorInner({
[disableTabIndent], [disableTabIndent],
); );
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
@@ -394,9 +410,9 @@ function EditorInner({
keymapCompartment.current.of( keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
), ),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({ ...getExtensions({
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
stateKey, stateKey,
@@ -470,7 +486,7 @@ function EditorInner({
const decoratedActions = useMemo(() => { const decoratedActions = useMemo(() => {
const results = []; const results = [];
const actionClassName = classNames( const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow", "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
); );
if (format) { if (format) {
@@ -553,7 +569,6 @@ function EditorInner({
function getExtensions({ function getExtensions({
stateKey, stateKey,
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
onChange, onChange,
@@ -562,7 +577,7 @@ function getExtensions({
onFocus, onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & { }: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"]; stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>; onChange: RefObject<EditorProps["onChange"]>;
@@ -580,6 +595,10 @@ function getExtensions({
return [ return [
...baseExtensions, // Must be first ...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
}),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
onFocus.current?.(); onFocus.current?.();
@@ -608,7 +627,6 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []), ...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ // // ------------------------ //
// Things that must be last // // Things that must be last //
@@ -15,8 +15,9 @@ export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}] fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
} }
const IDENT = /[A-Za-z0-9_/]+$/; const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/; const VALUE_IDENT = /\S+$/;
const VALUE_IDENT_ONLY = /^\S+$/;
function normalizeFields(fields: FieldDef[]): { function normalizeFields(fields: FieldDef[]): {
fieldNames: string[]; fieldNames: string[];
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap }; return { fieldNames, fieldMap };
} }
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null { function wordBefore(
doc: string,
pos: number,
pattern: RegExp,
): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos); const upto = doc.slice(0, pos);
const m = upto.match(IDENT); const m = upto.match(pattern);
if (!m) return null; if (!m) return null;
const from = pos - m[0].length; const from = pos - m[0].length;
return { from, to: pos, text: m[0] }; return { from, to: pos, text: m[0] };
} }
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
function inPhrase(ctx: CompletionContext): boolean { function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token // Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1); let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
if (inValue) { if (inValue) {
// word before the colon = field name // word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon); const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(IDENT); const m = beforeColon.match(FIELD_IDENT);
fieldName = m ? m[0] : null; fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon? // nothing (or only spaces) typed after the colon?
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
} }
/** Build a completion list for field names */ /** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[]): Completion[] { function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
return fieldNames.map((name) => ({ return fieldNames.map((name) => ({
label: name, label: name,
type: "property", type: "property",
apply: (view, _completion, from, to) => { apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon) // Leave cursor right after the field filter colon.
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({ view.dispatch({
changes: { from, to, insert: `${name}:` }, changes: { from, to, insert },
selection: { anchor: from + name.length + 1 }, selection: { anchor: from + insert.length },
}); });
startCompletion(view); startCompletion(view);
}, },
@@ -115,7 +140,7 @@ function fieldValueCompletions(
if (!def || !def.values) return null; if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values(); const vals = Array.isArray(def.values) ? def.values : def.values();
return vals.map((v) => ({ return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`, label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v, displayLabel: v,
type: "constant", type: "constant",
})); }));
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
return null; return null;
} }
const w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos); const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position // In field value position
if (inValue && fieldName) { if (inValue && fieldName) {
const w = wordBefore(doc, pos, VALUE_IDENT);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName]; const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs); const vals = fieldValueCompletions(valDefs);
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
} }
// Not in a value: suggest field names (and maybe boolean ops) // Not in a value: suggest field names (and maybe boolean ops)
const options: Completion[] = fieldNameCompletions(fieldNames); const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
return { from, to, options, filter: true }; return { from, to, options, filter: true };
}; };
@@ -2,10 +2,11 @@
@skip { space+ } @skip { space+ }
@tokens { @tokens {
space { std.whitespace+ } space { $[ \t\r\n]+ }
LParen { "(" } LParen { "(" }
RParen { ")" } RParen { ")" }
At { "@" }
Colon { ":" } Colon { ":" }
Not { "-" | "NOT" } Not { "-" | "NOT" }
@@ -16,8 +17,10 @@
// "quoted phrase" with simple escapes: \" and \\ // "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' } Phrase { '"' (!["\\] | "\\" _)* '"' }
// field/word characters (keep generous for URLs/paths) // Bare words run until filter syntax or whitespace. Leading '-' remains unary
Word { $[A-Za-z0-9_]+ } // negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word } @precedence { Not, And, Or, Word }
} }
@@ -60,12 +63,12 @@ Field {
} }
FieldName { FieldName {
Word At? Word
} }
FieldValue { FieldValue {
Phrase Phrase
| Term | FieldValueWord
} }
Term { Term {
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,27 +1,22 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from "@lezer/lr"; import {LRParser} from "@lezer/lr"
import { highlight } from "./highlight"; import {highlight} from "./highlight"
export const parser = LRParser.deserialize({ export const parser = LRParser.deserialize({
version: 14, version: 14,
states: states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p", stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
stateData: goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~", nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne", maxTerm: 27,
nodeNames:
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [ nodeProps: [
["openedBy", 8, "LParen"], ["openedBy", 8,"LParen"],
["closedBy", 9, "RParen"], ["closedBy", 9,"RParen"]
], ],
propSources: [highlight], propSources: [highlight],
skippedNodes: [0, 20], skippedNodes: [0,22],
repeatNodeCount: 3, repeatNodeCount: 3,
tokenData: tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[", tokenizers: [0, 1],
tokenizers: [0], topRules: {"Query":[0,1]},
topRules: { Query: [0, 1] }, tokenPrec: 145
tokenPrec: 145, })
});
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -0,0 +1,7 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -16,6 +16,7 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string" Phrase: t.string, // "quoted string"
// Fields // Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName, "FieldName/Word": t.attributeName,
"FieldValue/Term/Word": t.attributeValue, "FieldValue/FieldValueWord": t.attributeValue,
}); });
@@ -30,7 +30,8 @@ type Tok =
| { kind: "EOF" }; | { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c); const isSpace = (c: string) => /\s/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c); const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
export function tokenize(input: string): Tok[] { export function tokenize(input: string): Tok[] {
const toks: Tok[] = []; const toks: Tok[] = [];
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
const readWord = () => { const readWord = () => {
let s = ""; let s = "";
while (i < n && isIdent(peek())) s += advance(); while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s; return s;
}; };
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
if (c === ":") { if (c === ":") {
toks.push({ kind: "COLON" }); toks.push({ kind: "COLON" });
i++; i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue; continue;
} }
if (c === `"`) { if (c === `"`) {
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
} }
// WORD / AND / OR / NOT // WORD / AND / OR / NOT
if (isIdent(c)) { if (isWordStart(c)) {
const w = readWord(); const w = readWord();
const upper = w.toUpperCase(); const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" }); if (upper === "AND") toks.push({ kind: "AND" });
@@ -1,7 +1,7 @@
@top pairs { (Key Sep Value "\n")* } @top pairs { (Key Sep Value "\n")* }
@tokens { @tokens {
Sep { ":" } Sep { ":" $[ \t]+ }
Key { ":"? ![:]+ } Key { ":"? ![:]+ }
Value { ![\n]+ } Value { ![\n]+ }
} }
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./pairs";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "pairs") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("pairs grammar", () => {
test("parses colon-space pairs with a value", () => {
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
});
test("does not parse colon-without-space as a value", () => {
const nodes = getNodeNames("foo:bar\n");
expect(nodes).not.toContain("Value");
});
});
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 1, repeatNodeCount: 1,
tokenData: tokenData:
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh", "%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
tokenizers: [0, 1, 2], tokenizers: [0, 1, 2],
topRules: { pairs: [0, 1] }, topRules: { pairs: [0, 1] },
tokenPrec: 0, tokenPrec: 0,
@@ -53,19 +53,17 @@ function pathParameters(
if (node.name === "Text") { if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders // Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) { for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i); const innerTree = tree.resolveInner(i);
if (innerTree.node.name === "url") { if (innerTree.node.name === "url") {
innerTree.toTree().iterate({ innerTree.node.cursor().iterate((node) => {
enter(node) { if (node.name !== "Placeholder") return;
if (node.name !== "Placeholder") return; const globalFrom = node.from;
const globalFrom = innerTree.node.from + node.from; const globalTo = node.to;
const globalTo = innerTree.node.from + node.to; const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const rawText = view.state.doc.sliceString(globalFrom, globalTo); const onClick = () => onClickPathParameter(rawText);
const onClick = () => onClickPathParameter(rawText); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); const deco = Decoration.replace({ widget, inclusive: false });
const deco = Decoration.replace({ widget, inclusive: false }); widgets.push(deco.range(globalFrom, globalTo));
widgets.push(deco.range(globalFrom, globalTo));
},
}); });
break; break;
} }
@@ -1,6 +1,13 @@
@top url { Protocol? Host Path? Query? } // Host is optional so URLs starting with `/` go straight to Path. Without this,
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }
Path { ("/" (Placeholder | PathSegment))+ } Path { ("/" PathSegment)+ }
Placeholder { ":" pathChars }
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
Query { "?" queryPair ("&" queryPair)* } Query { "?" queryPair ("&" queryPair)* }
@@ -9,9 +16,7 @@ Query { "?" queryPair ("&" queryPair)* }
Host { $[a-zA-Z0-9-_.:\[\]]+ } Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host } @precedence { Protocol, Host }
Placeholder { ":" ![/?#]+ } pathChars { ![/?#:]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
} }
@@ -1,9 +1,9 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
export const url = 1, export const
url = 1,
Protocol = 2, Protocol = 2,
Host = 3, Host = 3,
Port = 4, Path = 4,
Path = 5, PathSegment = 5,
Placeholder = 6, Placeholder = 6,
PathSegment = 7, Query = 7
Query = 8;
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";
function expectValidParse(input: string) {
expect(parser.parse(input).toString()).not.toContain("⚠");
}
function placeholderValues(input: string): string[] {
const values: string[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
});
return values;
}
describe("URL grammar Placeholder", () => {
test("recognizes path placeholders", () => {
expectValidParse("https://x.com/users/:id");
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
});
test("treats a colon suffix as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
});
test("treats repeated colon suffixes as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
});
test("does not recognize a colon in the middle of a plain path segment", () => {
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
});
test("does not recognize query parameters as path placeholders", () => {
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
});
test("recognizes placeholders in a path fragment after a templated base URL", () => {
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
// as in `${[ URL ]}/x/:foo/:hello`.
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
":foo",
":hello",
]);
});
});

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