Compare commits

...

52 Commits

Author SHA1 Message Date
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
Gregory Schier dcfdf077e7 Add staged pre-commit checks 2026-05-13 14:22:43 -07:00
170 changed files with 11300 additions and 2544 deletions
+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 });
+1
View File
@@ -1 +1,2 @@
vp lint vp lint
vp staged
+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
+11 -9
View File
@@ -215,7 +215,7 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.52.0",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static 1.5.0", "lazy_static 1.5.0",
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.45" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"once_cell", "once_cell",
"rustix 1.0.7", "rustix 1.0.7",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -10052,6 +10052,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
@@ -10182,6 +10183,7 @@ dependencies = [
"webbrowser", "webbrowser",
"yaak", "yaak",
"yaak-api", "yaak-api",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
+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
@@ -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} />
),
});
} }
@@ -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"
@@ -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} />,
});
}, },
}, },
{ {
@@ -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-[10rem]">
{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-[7rem]", 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-[5rem] 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-none",
"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} />,
});
}, },
}, },
{ {
@@ -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,
@@ -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>
@@ -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,7 +77,7 @@ 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;
@@ -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>
@@ -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(
@@ -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
@@ -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__"
@@ -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>
} }
@@ -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,
], ],
); );
@@ -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 ? (
@@ -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>
@@ -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
);
}
+3 -1
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>
)} )}
@@ -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>
); );
} }
@@ -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
@@ -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 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",
+112 -6
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(
@@ -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 align-middle focus-visible:outline 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" && (
@@ -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);
@@ -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
@@ -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 !== ""
@@ -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";
@@ -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,
});
} }
@@ -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
@@ -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
@@ -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-[50rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
};
@@ -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,7 +39,9 @@ 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 flex-shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded outline-none ring-0", "rounded outline-none 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"}
/> />
@@ -1,57 +1,84 @@
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 { 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";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
actions?: { label: string; onClick: () => void; color?: Color }[]; 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;
return ( return (
<Banner <Banner className={classNames(className, "relative")} {...props}>
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")} <div className="@container">
{...props} <div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
> {children}
{children} <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<HStack space={1.5}> <Button
{actions?.map((a) => ( variant="border"
<Button color={props.color}
key={a.label} size="xs"
variant="border" onClick={() => {
color={a.color ?? props.color} setDismissed(true).catch(console.error);
size="xs" Promise.resolve(onDismiss?.()).catch(console.error);
onClick={a.onClick} }}
title={a.label} title="Dismiss message"
> >
{a.label} Dismiss
</Button> </Button>
))} {actions?.map((a) => (
<Button <Button
variant="border" key={a.label}
color={props.color} variant={a.variant ?? "border"}
size="xs" color={a.color ?? props.color}
onClick={() => setDismissed((d) => !d)} size="xs"
title="Dismiss message" onClick={a.onClick}
> title={a.label}
Dismiss >
</Button> {a.label}
</HStack> </Button>
))}
</div>
</div>
</div>
</Banner> </Banner>
); );
} }
@@ -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,
@@ -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",
]);
});
});
@@ -1,20 +1,18 @@
// 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: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c", stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~", goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[", nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 17,
maxTerm: 14,
propSources: [highlight], propSources: [highlight],
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 2, repeatNodeCount: 3,
tokenData: tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
tokenizers: [0, 1, 2], tokenizers: [0, 1, 2],
topRules: { url: [0, 1] }, topRules: {"url":[0,1]},
tokenPrec: 63, tokenPrec: 99
}); })
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller"; import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button"; import { Button } from "./Button";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
interface EventViewerProps<T> { interface EventViewerProps<T> {
@@ -151,7 +153,7 @@ export function EventViewer<T>({
layout="vertical" layout="vertical"
storageKey={splitLayoutStorageKey} storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio} defaultRatio={defaultRatio}
minHeightPx={10} minHeightPx={72}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
@@ -202,23 +204,38 @@ export function EventViewer<T>({
); );
} }
export interface EventDetailAction { export type EventDetailAction =
/** Unique key for React */ | {
key: string; type?: "button";
/** Button label */ /** Unique key for React */
label: string; key: string;
/** Optional icon */ /** Button label */
icon?: ReactNode; label: string;
/** Click handler */ /** Optional icon */
onClick: () => void; icon?: ReactNode;
} /** Click handler */
onClick: () => void;
}
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps { interface EventDetailHeaderProps {
title: string; title: string;
prefix?: ReactNode; prefix?: ReactNode;
timestamp?: string; timestamp?: string;
actions?: EventDetailAction[]; actions?: EventDetailAction[];
copyText?: string; copyText?: string | (() => Promise<string | null>);
onClose?: () => void; onClose?: () => void;
} }
@@ -239,34 +256,56 @@ export function EventDetailHeader({
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3> <h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack> </HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) =>
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}> action.type === "select" ? (
{action.icon} <div key={action.key} className="w-32">
{action.label} <Select
</Button> name={action.key}
))} label={action.label}
hideLabel
size="xs"
value={action.value}
options={action.options}
onChange={action.onChange}
/>
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && ( {copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" /> <CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)} )}
{formattedTime && ( {formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span> <span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)} )}
<div {onClose != null && (
className={classNames( <div
copyText != null || className={classNames(
formattedTime || copyText != null ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"), formattedTime ||
)} ((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
> )}
<IconButton >
color="custom" <IconButton
className="text-text-subtle -mr-3" color="custom"
size="xs" className="text-text-subtle -mr-3"
icon="x" size="xs"
title="Close event panel" icon="x"
onClick={onClose} title="Close event panel"
/> onClick={onClose}
</div> />
</div>
)}
</HStack> </HStack>
</div> </div>
); );
+3 -3
View File
@@ -290,10 +290,10 @@ function BaseInput({
<HStack <HStack
className={classNames( className={classNames(
inputWrapperClassName, inputWrapperClassName,
"w-full min-w-0 px-2", "flex-1 min-w-0 px-2",
fullHeight && "h-full", fullHeight && "h-full",
leftSlot ? "pl-0.5 -ml-2" : null, leftSlot ? "pl-0" : null,
rightSlot ? "pr-0.5 -mr-2" : null, rightSlot ? "pr-0" : null,
)} )}
> >
<Editor <Editor
@@ -1,16 +1,24 @@
import classNames from "classnames"; import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react"; import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
interface Props { interface Props {
children: children:
| ReactElement<HTMLAttributes<HTMLTableColElement>> | ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[]; | (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
} }
export function KeyValueRows({ children }: Props) { export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return ( return (
<table className="text-editor font-mono min-w-0 w-full mb-auto"> <table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight"> <tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => ( {childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key // oxlint-disable-next-line react/no-array-index-key
@@ -26,8 +34,11 @@ interface KeyValueRowProps {
children: ReactNode; children: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
leftSlot?: ReactNode; leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string; labelClassName?: string;
labelColor?: "secondary" | "primary" | "info"; labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
} }
export function KeyValueRow({ export function KeyValueRow({
@@ -35,14 +46,36 @@ export function KeyValueRow({
children, children,
rightSlot, rightSlot,
leftSlot, leftSlot,
align = "top",
labelColor = "secondary", labelColor = "secondary",
labelClassName, labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) { }: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={copyTitle}
iconSize="sm"
/>
) : null);
return ( return (
<> <>
<td <td
className={classNames( className={classNames(
"select-none py-0.5 pr-2 h-full align-top max-w-[10rem]", "select-none py-0.5 pr-2 h-full max-w-[10rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName, labelClassName,
labelColor === "primary" && "text-primary", labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle", labelColor === "secondary" && "text-text-subtle",
@@ -51,11 +84,21 @@ export function KeyValueRow({
> >
<span className="select-text cursor-text">{label}</span> <span className="select-text cursor-text">{label}</span>
</td> </td>
<td className="select-none py-0.5 break-all align-top max-w-[15rem]"> <td
className={classNames(
"select-none py-0.5 break-all max-w-[15rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]"> <div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />} {leftSlot ?? <span aria-hidden />}
{children} {children}
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />} {resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div> </div>
</td> </td>
</> </>
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui"; import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react"; import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -28,10 +28,9 @@ export type PlainInputProps = Omit<
| "extraExtensions" | "extraExtensions"
| "forcedEnvironmentId" | "forcedEnvironmentId"
> & > &
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & { Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"]; onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number"; type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean; hideObscureToggle?: boolean;
labelRightSlot?: ReactNode; labelRightSlot?: ReactNode;
}; };
@@ -43,6 +42,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
className, className,
containerClassName, containerClassName,
defaultValue, defaultValue,
disabled,
forceUpdateKey: forceUpdateKeyFromAbove, forceUpdateKey: forceUpdateKeyFromAbove,
help, help,
hideLabel, hideLabel,
@@ -51,6 +51,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName, labelClassName,
labelPosition = "top", labelPosition = "top",
labelRightSlot, labelRightSlot,
inputMode,
leftSlot, leftSlot,
name, name,
onBlur, onBlur,
@@ -63,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required, required,
rightSlot, rightSlot,
size = "md", size = "md",
step,
tint, tint,
type = "text", type = "text",
validate, validate,
@@ -163,7 +165,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
"relative w-full rounded-md text", "relative w-full rounded-md text",
"border", "border",
"overflow-hidden", "overflow-hidden",
focused ? "border-border-focus" : "border-border-subtle", focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation
size === "md" && "min-h-md", size === "md" && "min-h-md",
size === "sm" && "min-h-sm", size === "sm" && "min-h-sm",
@@ -198,15 +201,18 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
// oxlint-disable-next-line jsx-a11y/no-autofocus // oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined} defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))} onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full")} className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
required={required} required={required}
step={step}
placeholder={placeholder} placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
/> />
+9 -1
View File
@@ -109,7 +109,15 @@ export function Select<T extends string>({
) : ( ) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in // Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode // light mode
<RadioDropdown value={value} onChange={handleChange} items={options}> <RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button <Button
className="w-full text-sm font-mono" className="w-full text-sm font-mono"
justify="start" justify="start"
@@ -24,7 +24,7 @@ export function Separator({
)} )}
<div <div
className={classNames( className={classNames(
"h-0 border-t opacity-60", "opacity-60",
color == null && "border-border", color == null && "border-border",
color === "primary" && "border-primary", color === "primary" && "border-primary",
color === "secondary" && "border-secondary", color === "secondary" && "border-secondary",
@@ -34,8 +34,8 @@ export function Separator({
color === "danger" && "border-danger", color === "danger" && "border-danger",
color === "info" && "border-info", color === "info" && "border-info",
dashed && "border-dashed", dashed && "border-dashed",
orientation === "horizontal" && "w-full h-[1px]", orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-[1px]", orientation === "vertical" && "h-full w-0 border-l",
)} )}
/> />
</div> </div>
@@ -0,0 +1,514 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
return (
<SettingRowBoolean
checked={model[modelKey] as boolean}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "!w-48",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={String(modelKey)}
value={model[modelKey] as number}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "!w-80",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={String(modelKey)}
value={model[modelKey] as string}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowFile({
buttonClassName,
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
directory,
filePath,
nameOverride,
noun,
onChange,
size = "xs",
title,
...props
}: {
buttonClassName?: string;
directory?: boolean;
filePath: string | null;
nameOverride?: string | null;
noun?: string;
onChange: (filePath: string | null) => void | Promise<void>;
size?: Parameters<typeof SelectFile>[0]["size"];
} & SettingRowBaseProps) {
return (
<SettingRow title={title} controlClassName={controlClassName} {...props}>
<SelectFile
directory={directory}
inline
hideLabel
label={typeof title === "string" ? title : noun}
size={size}
noun={noun}
nameOverride={nameOverride}
filePath={filePath}
className={buttonClassName}
onChange={({ filePath }) => onChange(filePath)}
/>
</SettingRow>
);
}
export function SettingRowDirectory({
noun = "Directory",
...props
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "!w-48",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "!w-48",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function ModelSettingRowSelect<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
@@ -1,6 +1,6 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git"; import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git"; import type { GitCommit } from "@yaakapp-internal/git";
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui"; import { SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -8,7 +8,7 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui"; import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml"; import { modelToYaml } from "../../lib/diffYaml";
@@ -16,6 +16,7 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync"; import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
@@ -205,7 +206,8 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full px-4"> <div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
@@ -596,7 +596,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
color: "success", color: "success",
label: "Open Workspace Settings", label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />, leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("data"), onSelect: () => openWorkspaceSettings("settings"),
}, },
{ type: "separator" }, { type: "separator" },
{ {
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote", title: "Add Remote",
inputs: [ inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName }, { type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url" }, { type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
], ],
}); });
if (r == null) throw new Error("Cancelled remote prompt"); if (r == null) throw new Error("Cancelled remote prompt");
@@ -1,21 +1,38 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource"; import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType"; import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer"; import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow"; import { EventViewerRow } from "../core/EventViewerRow";
import { IconButton } from "../core/IconButton";
import { IconTooltip } from "../core/IconTooltip";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
} }
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) { export function EventStreamViewer({ response }: Props) {
return ( return (
<Fragment <Fragment
@@ -29,64 +46,316 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) { function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false); const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false); const [showingLarge, setShowingLarge] = useState<boolean>(false);
const filterEventPreviewsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_filter_event_previews", response.requestId],
fallback: false,
});
const applyToDetailsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_apply_to_details", response.requestId],
fallback: false,
});
const renderMarkdownSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_render_markdown", response.requestId],
fallback: false,
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response); const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
showExtractedText &&
summary.data != null &&
summary.data.fragmentCount === 0 &&
!summary.isFetching &&
summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
const settingsItems = useMemo<DropdownItem[]>(
() => [
{
label: "Apply to Previews",
keepOpenOnSelect: true,
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
leftSlot: (
<Icon
icon={
filterEventPreviewsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
{
label: "Apply to Details",
keepOpenOnSelect: true,
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
leftSlot: (
<Icon
icon={
applyToDetailsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
return ( return (
<EventViewer <div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
events={events.data ?? []} <HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
getEventKey={(_, index) => String(index)} <div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
error={events.error ? String(events.error) : null} <Select
splitLayoutStorageKey="sse_events" name={`sse-summary-result-key-path-enabled::${response.requestId}`}
defaultRatio={0.4} label="Extracted text"
renderRow={({ event, index, isActive, onClick }) => ( hideLabel
<EventViewerRow size="xs"
isActive={isActive} value={summarySettings.enabled ? "jsonpath" : "off"}
onClick={onClick} options={[
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />} { label: "Full events", value: "off" },
content={ { label: "JSONPath", value: "jsonpath" },
<HStack space={2} className="items-center"> ]}
<EventLabels event={event} index={index} isActive={isActive} /> onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> />
</HStack> </div>
} {summarySettings.enabled && (
/> <>
)} <div className="min-w-40 flex-1">
renderDetail={({ event, index, onClose }) => ( <Input
<EventDetail label="Result JSON path"
event={event} hideLabel
index={index} size="xs"
showLarge={showLarge} autocomplete={sseSummaryResultKeyPathAutocomplete}
showingLarge={showingLarge} defaultValue={summarySettings.resultKeyPathInputValue}
setShowLarge={setShowLarge} forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
setShowingLarge={setShowingLarge} placeholder="$.choices[0].delta.content"
onClose={onClose} rightSlot={
/> showResultKeyPathWarning ? (
)} <div className="flex items-center px-2">
/> <IconTooltip
tabIndex={-1}
icon="alert_triangle"
iconColor="notice"
content="No text fragments matched this JSONPath."
/>
</div>
) : null
}
stateKey={`sse-summary-result-key-path::${response.requestId}`}
tint={showResultKeyPathWarning ? "notice" : undefined}
onChange={summarySettings.setResultKeyPath}
/>
</div>
<Dropdown items={settingsItems}>
<IconButton
size="xs"
variant="border"
icon="settings"
title="Extracted text settings"
/>
</Dropdown>
</>
)}
</HStack>
<SplitLayout
layout="vertical"
storageKey={`sse_extracted_text::${response.requestId}`}
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
minHeightPx={72}
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</div>
)}
secondSlot={
showExtractedText
? ({ style }) => (
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
)
: null
}
/>
</div>
); );
} }
function SseSummaryFooter({
error,
fragmentCount,
isLoading,
onRenderMarkdownChange,
renderMarkdown,
resultKeyPath,
style,
summary,
}: {
error: string | null;
fragmentCount: number;
isLoading: boolean;
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
renderMarkdown: boolean;
resultKeyPath: string;
style: CSSProperties;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const actions = useMemo(
() => [
{
key: "sse-summary-format",
label: "Extracted text format",
type: "select" as const,
value: renderMarkdown ? "markdown" : "text",
options: [
{ label: "Text", value: "text" },
{ label: "Markdown", value: "markdown" },
],
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
},
],
[onRenderMarkdownChange, renderMarkdown],
);
return (
<div
style={style}
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
>
<div className="pt-2">
<EventDetailHeader
actions={actions}
title="Extracted Text"
copyText={hasSummary ? summary : undefined}
/>
</div>
<div
className={classNames(
"min-h-0 py-2 overflow-auto",
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
)}
>
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading extracted text...</span>
) : hasSummary ? (
renderMarkdown ? (
<div className="min-h-0">
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
</div>
) : (
<pre className="font-mono whitespace-pre-wrap break-words select-auto cursor-auto">
{summary}
</pre>
)
) : (
<EmptyStateText className="gap-1.5">
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</EmptyStateText>
)}
</div>
</div>
);
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
filterEventPreview: boolean,
): string {
if (filterEventPreview && resultKeyPath != null) {
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
}
return event.data.slice(0, 1000);
}
function EventDetail({ function EventDetail({
applyJsonPath,
event, event,
index, index,
resultKeyPath,
showLarge, showLarge,
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose, onClose,
}: { }: {
applyJsonPath: boolean;
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
resultKeyPath: string | null;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const detailText = useMemo(
() =>
applyJsonPath && resultKeyPath != null
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
: event.data,
[applyJsonPath, event.data, resultKeyPath],
);
const language = useMemo<"text" | "json">(() => { const language = useMemo<"text" | "json">(() => {
if (!event?.data) return "text"; if (!detailText) return "text";
return isJSON(event?.data) ? "json" : "text"; return isJSON(detailText) ? "json" : "text";
}, [event?.data]); }, [detailText]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -95,7 +364,7 @@ function EventDetail({
prefix={<EventLabels event={event} index={index} />} prefix={<EventLabels event={event} index={index} />}
onClose={onClose} onClose={onClose}
/> />
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && detailText.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
<div> <div>
@@ -117,7 +386,7 @@ function EventDetail({
</div> </div>
</VStack> </VStack>
) : ( ) : (
<FormattedEditor language={language} text={event.data} /> <FormattedEditor language={language} text={detailText} />
)} )}
</div> </div>
); );
@@ -142,14 +411,17 @@ function EventLabels({
}) { }) {
return ( return (
<HStack space={1.5} alignItems="center" className={className}> <HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}> <EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.id ?? index} {event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.eventType}
</InlineCode>
)}
</HStack> </HStack>
); );
} }
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
<span className="relative">{children}</span>
</InlineCode>
);
}
@@ -69,6 +69,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
text={text} text={text}
language={language} language={language}
stateKey={`response.body.${response.id}`} stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty} pretty={pretty}
className={className} className={className}
onFilter={filterCallback} onFilter={filterCallback}
@@ -16,6 +16,7 @@ interface Props {
text: string; text: string;
language: EditorProps["language"]; language: EditorProps["language"];
stateKey: string | null; stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean; pretty?: boolean;
className?: string; className?: string;
onFilter?: (filter: string) => { onFilter?: (filter: string) => {
@@ -27,16 +28,25 @@ interface Props {
const useFilterText = createGlobalState<Record<string, string | null>>({}); const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) { export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
onFilter,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText(); const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText); const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback( const setFilterText = useCallback(
(v: string | null) => { (v: string | null) => {
if (!stateKey) return; if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v })); setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
}, },
[setFilterTextMap, stateKey], [filterKey, setFilterTextMap],
); );
const isSearching = filterText != null; const isSearching = filterText != null;
@@ -64,7 +74,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
nodes.push( nodes.push(
<div key="input" className="w-full !opacity-100"> <div key="input" className="w-full !opacity-100">
<Input <Input
key={stateKey ?? "filter"} key={filterKey ?? "filter"}
validate={!filteredResponse.error} validate={!filteredResponse.error}
hideLabel hideLabel
autoFocus autoFocus
@@ -76,7 +86,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
defaultValue={filterText} defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()} onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText} onChange={setFilterText}
stateKey={stateKey ? `filter.${stateKey}` : null} stateKey={filterKey ? `filter.${filterKey}` : null}
/> />
</div>, </div>,
); );
@@ -97,12 +107,12 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
return nodes; return nodes;
}, [ }, [
canFilter, canFilter,
filterKey,
filterText, filterText,
filteredResponse.error, filteredResponse.error,
filteredResponse.isPending, filteredResponse.isPending,
isSearching, isSearching,
language, language,
stateKey,
setFilterText, setFilterText,
toggleSearch, toggleSearch,
]); ]);
+160 -123
View File
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip"; import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs"; import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize"; import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
@@ -14,156 +15,192 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication"; import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors"; import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) { export function useAuthTab<T extends string>(
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries(); const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model); const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null; const parentModel = ancestors[0] ?? null;
return useMemo<TabItem[]>(() => { return useMemo(() => {
if (model == null) return []; if (model == null) return null;
const tab: TabItem = { return {
value: tabValue, value: model.authenticationType,
label: "Auth", items: [
options: { ...authentication.map((a) => ({
value: model.authenticationType, label: a.label || "UNKNOWN",
items: [ shortLabel: a.shortLabel,
...authentication.map((a) => ({ value: a.name,
label: a.label || "UNKNOWN", })),
shortLabel: a.shortLabel, { type: "separator" },
value: a.name, {
})), label: "Inherit from Parent",
{ type: "separator" }, shortLabel:
{ inheritedAuth != null &&
label: "Inherit from Parent", inheritedAuth.authenticationType !== "none" ? (
shortLabel: <HStack space={1.5}>
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? ( {authentication.find(
<HStack space={1.5}> (a) => a.name === inheritedAuth.authenticationType,
{authentication.find((a) => a.name === inheritedAuth.authenticationType) )?.shortLabel ?? "UNKNOWN"}
?.shortLabel ?? "UNKNOWN"} <IconTooltip
<IconTooltip icon="zap_off"
icon="magic_wand" iconSize="xs"
iconSize="xs" content="Authentication was inherited from an ancestor"
content="Authentication was inherited from an ancestor" />
/> </HStack>
</HStack> ) : (
) : ( "Auth"
"Auth" ),
), value: null,
value: null, },
}, { label: "No Auth", shortLabel: "No Auth", value: "none" },
{ label: "No Auth", shortLabel: "No Auth", value: "none" }, ],
], itemsAfter: (() => {
itemsAfter: (() => { const actions: (
const actions: ( | { type: "separator"; label: string }
| { type: "separator"; label: string } | {
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> } label: string;
)[] = []; leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null || parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
} }
actions.push({ )[] = [];
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: ( leftSlot: (
<Icon <Icon
icon={ icon={
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down" parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
} }
/> />
), ),
onSelect: async () => { onSelect: async () => {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
id: "copy-auth-confirm", id: "promote-auth-confirm",
title: "Copy Authentication", title: "Promote Authentication",
confirmText: "Copy", confirmText: "Promote",
description: ( description: (
<> <>
Copy{" "} Move authentication config to{" "}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType) <InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
?.label ?? "authentication"}{" "}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{" "}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</> </>
), ),
}); });
if (confirmed) { if (confirmed) {
await patchModel(model, { await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication }, authentication: {},
authenticationType: ancestorWithAuth.authenticationType, authenticationType: null,
}); });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
} }
}, },
}); },
} );
}
return actions.length > 0 ? actions : undefined; // Copy from ancestor: copy auth config down to current model
})(), const ancestorWithAuth = ancestors.find(
onChange: async (authenticationType) => { (a) =>
let authentication: Folder["authentication"] = model.authentication; a.authenticationType != null && a.authenticationType !== "none",
if (model.authenticationType !== authenticationType) { );
authentication = { if (ancestorWithAuth) {
// Reset auth if changing types if (actions.length === 0) {
}; actions.push({ type: "separator", label: "Actions" });
} }
await patchModel(model, { authentication, authenticationType }); actions.push({
}, label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Copy{" "}
{authentication.find(
(a) => a.name === ancestorWithAuth.authenticationType,
)?.label ?? "authentication"}{" "}
config from{" "}
<InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
}, },
}; };
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
+4
View File
@@ -14,6 +14,7 @@ export type HotkeyAction =
| "app.zoom_out" | "app.zoom_out"
| "app.zoom_reset" | "app.zoom_reset"
| "command_palette.toggle" | "command_palette.toggle"
| "cookies_editor.show"
| "editor.autocomplete" | "editor.autocomplete"
| "environment_editor.toggle" | "environment_editor.toggle"
| "hotkeys.showHelp" | "hotkeys.showHelp"
@@ -43,6 +44,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Meta+Minus"], "app.zoom_out": ["Meta+Minus"],
"app.zoom_reset": ["Meta+0"], "app.zoom_reset": ["Meta+0"],
"command_palette.toggle": ["Meta+k"], "command_palette.toggle": ["Meta+k"],
"cookies_editor.show": ["Meta+Shift+k"],
"editor.autocomplete": ["Control+Space"], "editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Meta+Shift+e"], "environment_editor.toggle": ["Meta+Shift+e"],
"request.rename": ["Control+Shift+r"], "request.rename": ["Control+Shift+r"],
@@ -73,6 +75,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Control+Minus"], "app.zoom_out": ["Control+Minus"],
"app.zoom_reset": ["Control+0"], "app.zoom_reset": ["Control+0"],
"command_palette.toggle": ["Control+k"], "command_palette.toggle": ["Control+k"],
"cookies_editor.show": ["Control+Shift+k"],
"editor.autocomplete": ["Control+Space"], "editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Control+Shift+e"], "environment_editor.toggle": ["Control+Shift+e"],
"request.rename": ["F2"], "request.rename": ["F2"],
@@ -128,6 +131,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
"app.zoom_out": "Zoom Out", "app.zoom_out": "Zoom Out",
"app.zoom_reset": "Zoom to Actual Size", "app.zoom_reset": "Zoom to Actual Size",
"command_palette.toggle": "Toggle Command Palette", "command_palette.toggle": "Toggle Command Palette",
"cookies_editor.show": "Show Cookies",
"editor.autocomplete": "Trigger Autocomplete", "editor.autocomplete": "Trigger Autocomplete",
"environment_editor.toggle": "Edit Environments", "environment_editor.toggle": "Edit Environments",
"hotkeys.showHelp": "Show Keyboard Shortcuts", "hotkeys.showHelp": "Show Keyboard Shortcuts",
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Appearance } from "../lib/theme/appearance"; import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance"; import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
export function usePreferredAppearance() { export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance()); const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
@@ -1,6 +1,6 @@
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() { export function useResolvedAppearance() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/theme/themes"; import { getResolvedTheme, getThemes } from "../lib/themes";
import { usePluginsKey } from "./usePlugins"; import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
@@ -6,7 +6,12 @@ import { getResponseBodyEventSource } from "../lib/responseBody";
export function useResponseBodyEventSource(response: HttpResponse) { export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({ return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["response-body-event-source", response.id, response.contentLength], queryKey: [
"response-body-event-source",
response.id,
response.updatedAt,
response.contentLength,
],
queryFn: () => getResponseBodyEventSource(response), queryFn: () => getResponseBodyEventSource(response),
}); });
} }
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { SseSummary } from "@yaakapp-internal/sse";
import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
});
}
+17 -25
View File
@@ -1,40 +1,32 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models"; import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment"; import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation"; import { createFastMutation, useFastMutation } from "./useFastMutation";
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
if (id == null) {
return null;
}
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", {
requestId: id,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
}
export function useSendAnyHttpRequest() { export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({ return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"], mutationKey: ["send_any_request"],
mutationFn: async (id) => { mutationFn: sendAnyHttpRequestById,
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null;
}
return invokeCmd("cmd_send_http_request", {
request,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
},
}); });
} }
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({ export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"], mutationKey: ["send_any_request"],
mutationFn: async (id) => { mutationFn: sendAnyHttpRequestById,
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null;
}
return invokeCmd("cmd_send_http_request", {
request,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
},
}); });
@@ -0,0 +1,98 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
import { useKeyValue } from "./useKeyValue";
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
{
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
detail: "ChatGPT (OpenAI)",
type: "constant",
boost: 1,
},
{
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
detail: "Responses (OpenAI)",
type: "constant",
boost: 1,
},
{
label: ANTHROPIC_RESULT_KEY_PATH,
detail: "Claude (Anthropic)",
type: "constant",
boost: 1,
},
{
label: GOOGLE_RESULT_KEY_PATH,
detail: "Gemini (Google)",
type: "constant",
boost: 1,
},
];
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
minMatch: 0,
options: sseSummaryResultKeyPathOptions,
};
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
const storedResultKeyPath = useKeyValue<string | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path", response.requestId],
fallback: null,
});
const enabled = useKeyValue<boolean | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path_enabled", response.requestId],
fallback: null,
});
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
return {
enabled: isEnabled,
inferredResultKeyPath,
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
resultKeyPathInputValue: resultKeyPath ?? "",
setEnabled: enabled.set,
setResultKeyPath: storedResultKeyPath.set,
};
}
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
let url: URL;
try {
url = new URL(response.url);
} catch {
return null;
}
const hostname = url.hostname.toLowerCase();
const pathname = url.pathname.toLowerCase();
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
}
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
return OPENAI_RESPONSES_RESULT_KEY_PATH;
}
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
return ANTHROPIC_RESULT_KEY_PATH;
}
if (
hostname === "generativelanguage.googleapis.com" &&
pathname.includes(":streamgeneratecontent")
) {
return GOOGLE_RESULT_KEY_PATH;
}
return null;
}
@@ -35,10 +35,15 @@ export async function deleteModelWithConfirm(
<> <>
the following? the following?
<Prose className="mt-2"> <Prose className="mt-2">
<ul> <ul className="space-y-1">
{models.map((m) => ( {models.map((m) => (
<li key={m.id}> <li key={m.id}>
<InlineCode>{resolvedModelName(m)}</InlineCode> <InlineCode
className="inline-block truncate align-bottom max-w-full"
title={resolvedModelName(m)}
>
{resolvedModelName(m)}
</InlineCode>
</li> </li>
))} ))}
</ul> </ul>
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
color: "danger", color: "danger",
timeout: null, timeout: null,
message: `Failed to load plugin "${name}": ${err}`, message: `Failed to load plugin "${name}": ${err}`,
action: ({ hide }) => (
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
hide();
openSettings.mutate("plugins:installed");
}}
>
Manage Plugins
</Button>
),
}); });
} }
}); });
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vite-plus/test";
import { extractPathPlaceholders } from "./pathPlaceholders";
describe("extractPathPlaceholders", () => {
test("extracts a single placeholder", () => {
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
});
test("extracts multiple placeholders", () => {
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
});
test("stops at a literal `:` in the same segment", () => {
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
});
test("does not match `:foo` mid-segment", () => {
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
});
test("does not match `:` in a host port", () => {
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
});
test("returns empty for a URL with no placeholders", () => {
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Extract `:name`-style path placeholders from a URL string.
*
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
* placeholder `:id` and `:increment-importance` is literal text.
*
* Only `:` that sits at the start of a `/`-delimited segment counts `/abc:def`
* has no placeholders. Returned names include the leading colon.
*/
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
+3
View File
@@ -0,0 +1,3 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
+101
View File
@@ -0,0 +1,101 @@
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
models: readonly ModelTypeWithSetting<K>[];
title: string;
};
export type RequestSettingKey = keyof WorkspaceRequestSettings;
function defineRequestSetting<const K extends RequestSettingKey>(
setting: RequestSettingDefinition<K>,
) {
return setting;
}
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
defaultValue: 0,
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
modelKey: "settingRequestTimeout",
models: ["workspace", "folder", "http_request"],
title: "Request Timeout",
});
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates",
});
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
defaultValue: true,
description: "Follow HTTP redirects automatically.",
modelKey: "settingFollowRedirects",
models: ["workspace", "folder", "http_request"],
title: "Follow redirects",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
});
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
});
export function modelSupportsSetting<K extends RequestSettingKey>(
model: Pick<AnyModel, "model">,
setting: RequestSettingDefinition<K>,
) {
return setting.models.some((modelType) => modelType === model.model);
}
+32 -4
View File
@@ -1,7 +1,8 @@
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { FilterResponse } from "@yaakapp-internal/plugins"; import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { invokeCmd } from "./tauri"; import { invokeCmd } from "./tauri";
export async function getResponseBodyText({ export async function getResponseBodyText({
@@ -27,9 +28,36 @@ export async function getResponseBodyEventSource(
response: HttpResponse, response: HttpResponse,
): Promise<ServerSentEvent[]> { ): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return []; if (!response.bodyPath) return [];
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", { try {
filePath: response.bodyPath, const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
}); filePath: response.bodyPath,
});
if (events.length > 0) {
return events;
}
} catch {
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
id: String(index),
retry: null,
}));
}
export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
} }
export async function getResponseBodyBytes( export async function getResponseBodyBytes(
-8
View File
@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";
-9
View File
@@ -1,9 +0,0 @@
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";
-1
View File
@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";
@@ -1,10 +1,11 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins"; import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; import {
import { invokeCmd } from "../tauri"; defaultDarkTheme,
import type { Appearance } from "./appearance"; defaultLightTheme,
import { resolveAppearance } from "./appearance"; resolveAppearance,
type Appearance,
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; } from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
export async function getThemes() { export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes); const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
+4 -4
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.0.1", "react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.0", "react-use": "^17.6.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -98,15 +98,15 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"decompress": "^4.2.1", "decompress": "^4.2.1",
"internal-ip": "^8.0.0", "internal-ip": "^8.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.14",
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
"rollup": "^4.60.3", "rollup": "^4.60.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plugin-static-copy": "^3.3.0", "vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0", "vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+7 -4
View File
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window"; import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models"; import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme";
import {
applyThemeToDocument,
getCSSAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings"; import { getSettings } from "./lib/settings";
import type { Appearance } from "./lib/theme/appearance"; import { getResolvedTheme } from "./lib/themes";
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want // NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long // a good appearance guess so we're not waiting too long
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
}), }),
], ],
build: { build: {
target: "esnext",
sourcemap: true, sourcemap: true,
outDir: "../../dist/apps/yaak-client", outDir: "../../dist/apps/yaak-client",
emptyOutDir: true, emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+1
View File
@@ -42,6 +42,7 @@ webbrowser = "1"
zip = "4" zip = "4"
yaak = { workspace = true } yaak = { workspace = true }
yaak-api = { workspace = true } yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
+38
View File
@@ -42,6 +42,12 @@ pub enum Commands {
/// Authentication commands /// Authentication commands
Auth(AuthArgs), Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
Export(ExportArgs),
/// Plugin development and publishing commands /// Plugin development and publishing commands
Plugin(PluginArgs), Plugin(PluginArgs),
@@ -92,6 +98,34 @@ pub struct SendArgs {
pub fail_fast: bool, pub fail_fast: bool,
} }
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
pub workspace_id: Option<String>,
}
#[derive(Args)]
pub struct ExportArgs {
/// Path to write the Yaak export JSON file
pub file: PathBuf,
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
#[arg(value_name = "WORKSPACE_ID")]
pub workspace_ids: Vec<String>,
/// Export all workspaces
#[arg(long, conflicts_with = "workspace_ids")]
pub all: bool,
/// Include private environments in the export
#[arg(long)]
pub include_private_environments: bool,
}
#[derive(Args)] #[derive(Args)]
#[command(disable_help_subcommand = true)] #[command(disable_help_subcommand = true)]
pub struct CookieJarArgs { pub struct CookieJarArgs {
@@ -447,6 +481,10 @@ pub enum PluginCommands {
/// Install a plugin from a local directory or from the registry /// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs), Install(InstallPluginArgs),
/// Generate plugin metadata for the registry
#[command(hide = true)]
Metadata(PluginPathArg),
/// Publish a Yaak plugin version to the plugin registry /// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg), Publish(PluginPathArg),
} }
@@ -0,0 +1,176 @@
use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await {
Ok(result) => {
println!("Imported {}", format_counts(&result));
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
match export(ctx, args) {
Ok(count) => {
println!("Exported {count} workspace(s)");
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
return Err(
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
export::export_data(ExportDataParams {
query_manager: ctx.query_manager(),
yaak_version: env!("CARGO_PKG_VERSION"),
export_path: &args.file,
workspace_ids: workspace_id_refs,
include_private_environments: args.include_private_environments,
})
.map_err(|e| format!("Failed to export data: {e}"))?;
Ok(workspace_ids.len())
}
fn resolve_export_workspace_ids(
ctx: &CliContext,
workspace_ids: Vec<String>,
all: bool,
) -> CommandResult<Vec<String>> {
if all {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
return Err("No workspaces found to export".to_string());
}
return Ok(workspaces.into_iter().map(|w| w.id).collect());
}
if workspace_ids.is_empty() {
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
}
for workspace_id in &workspace_ids {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
e.workspace_id == "CURRENT_WORKSPACE"
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.folders.iter().any(|f| {
f.workspace_id == "CURRENT_WORKSPACE"
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.http_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.grpc_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.websocket_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
}
fn format_counts(result: &BatchUpsertResult) -> String {
let names = [
"workspace",
"environment",
"folder",
"HTTP request",
"gRPC request",
"WebSocket request",
];
let counts = [
(result.workspaces.len(), names[0]),
(result.environments.len(), names[1]),
(result.folders.len(), names[2]),
(result.http_requests.len(), names[3]),
(result.grpc_requests.len(), names[4]),
(result.websocket_requests.len(), names[5]),
];
let non_zero: Vec<String> = counts
.into_iter()
.filter(|(count, _)| *count > 0)
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
.collect();
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
}
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod cookie_jar; pub mod cookie_jar;
pub mod environment; pub mod environment;
pub mod folder; pub mod folder;
pub mod import_export;
pub mod plugin; pub mod plugin;
pub mod request; pub mod request;
pub mod send; pub mod send;

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