mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-02 19:11:39 +02:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ac3e310a | |||
| 9b524e3dc7 | |||
| bdf78254b5 | |||
| c5545c8781 | |||
| 5004c395de | |||
| ea3587f28d | |||
| 24e578db5f | |||
| 12562aa076 | |||
| 5a74a989b5 | |||
| a6558329e2 | |||
| 54a931d94d | |||
| 5229534d8f | |||
| 78b3996f47 | |||
| d9f7bf7fdd | |||
| 45c410dd4c | |||
| 80e56281b2 | |||
| 125eae052b | |||
| 6f52bb7533 | |||
| 8724260eb4 | |||
| f32e9f7704 | |||
| 83c8371e94 | |||
| 5f14d90ccd | |||
| ff0d8c03b0 | |||
| 1dd7e728ff | |||
| 3a349bccfe | |||
| 13a667a9b1 | |||
| 420c6e2c4a | |||
| bbdfbcb9ca | |||
| d1e6f8fb33 | |||
| 930a816f42 | |||
| ec0143aa93 | |||
| 3cc54dea22 | |||
| a8fb144c09 | |||
| 6813fa8bf2 | |||
| cf7de26a2e | |||
| 8676272657 | |||
| c3aecfdc0c | |||
| 09adcda2d9 | |||
| 18b983bfe5 | |||
| 9ffd8d4810 | |||
| 55d0066efd | |||
| 1de0a5942c | |||
| fd0ca6d455 | |||
| 84b89e2708 | |||
| 7db3e9b879 | |||
| 8109a28967 | |||
| 3de9a1edd4 | |||
| 1b28dfd9d1 | |||
| 9f51c61447 | |||
| b17ccbeebe | |||
| 463cc6f5a3 | |||
| 1307ea4e67 | |||
| 710b8e34ac | |||
| f251772a4a | |||
| fa40ceaa31 | |||
| dcfdf077e7 |
@@ -19,10 +19,12 @@ Generate formatted markdown release notes for a Yaak tag.
|
||||
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
|
||||
5. Extract useful details:
|
||||
- Feedback URLs (`feedback.yaak.app`)
|
||||
- Contributor GitHub handles from `author.login`
|
||||
- Plugin install links or other notable context
|
||||
6. Format notes using Yaak style:
|
||||
- Changelog badge at top
|
||||
- Bulleted items with PR links where available
|
||||
- Contributor handles for external PRs
|
||||
- Feedback links where available
|
||||
- Full changelog compare link at bottom
|
||||
|
||||
@@ -31,6 +33,7 @@ Generate formatted markdown release notes for a Yaak tag.
|
||||
- Wrap final notes in a markdown code fence.
|
||||
- Keep a blank line before and after the code fence.
|
||||
- Output the markdown code block last.
|
||||
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
|
||||
- Do not append `by @gschier` for PRs authored by `@gschier`.
|
||||
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
## Submission
|
||||
|
||||
- [ ] This PR is a bug fix or small-scope improvement.
|
||||
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
|
||||
- [ ] This PR is a bug fix.
|
||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
- [ ] I tested this change locally.
|
||||
- [ ] I added or updated tests when reasonable.
|
||||
- [ ] I added screenshots or recordings for UI changes when reasonable.
|
||||
|
||||
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/... -->
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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,7 +1,12 @@
|
||||
name: Update Flathub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish to Flathub
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,8 +15,6 @@ jobs:
|
||||
update-flathub:
|
||||
name: Update Flathub manifest
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for stable releases (skip betas/pre-releases)
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Checkout app repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -39,7 +42,7 @@ jobs:
|
||||
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
|
||||
|
||||
- name: Run update-manifest.sh
|
||||
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
|
||||
run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo
|
||||
|
||||
- name: Commit and push to Flathub
|
||||
working-directory: flathub-repo
|
||||
@@ -48,5 +51,5 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet && echo "No changes to commit" && exit 0
|
||||
git commit -m "Update to ${{ github.event.release.tag_name }}"
|
||||
git commit -m "Update to ${{ inputs.tag }}"
|
||||
git push
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
vp lint
|
||||
vp staged
|
||||
|
||||
+1
-2
@@ -3,13 +3,12 @@
|
||||
Yaak accepts community pull requests for:
|
||||
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Generated
+11
-9
@@ -215,7 +215,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.1",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static 1.5.0",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6534,7 +6534,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6547,7 +6547,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -7988,7 +7988,7 @@ dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.7",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9317,7 +9317,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10052,6 +10052,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
@@ -10182,6 +10183,7 @@ dependencies = [
|
||||
"webbrowser",
|
||||
"yaak",
|
||||
"yaak-api",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
|
||||
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
## Contribution Policy
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
||||
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
|
||||
> Community PRs are currently limited to bug fixes.
|
||||
> 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.
|
||||
|
||||
## Useful Resources
|
||||
|
||||
@@ -10,7 +10,7 @@ export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
id: "folder-settings",
|
||||
title: null,
|
||||
size: "lg",
|
||||
className: "h-[50rem]",
|
||||
className: "h-200",
|
||||
noPadding: true,
|
||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function BinaryFileEditor({
|
||||
<VStack space={2}>
|
||||
<SelectFile onChange={handleChange} filePath={filePath} />
|
||||
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
|
||||
<Banner className="mt-3 !py-5">
|
||||
<Banner className="mt-3 py-5!">
|
||||
<div className="mb-4 text-center">
|
||||
<div>Set Content-Type header</div>
|
||||
<InlineCode>{mimeType}</InlineCode> for current request?
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
@@ -89,6 +90,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
|
||||
|
||||
<PlainInput
|
||||
required
|
||||
label="Repository URL"
|
||||
@@ -105,7 +108,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
rightSlot={
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="mr-0.5 !h-auto my-0.5"
|
||||
className="mr-0.5 h-auto! my-0.5"
|
||||
icon="folder"
|
||||
title="Browse"
|
||||
onClick={handleSelectDirectory}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
|
||||
const style: CSSProperties = { backgroundColor: color ?? undefined };
|
||||
const finalClassName = classNames(
|
||||
className,
|
||||
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
|
||||
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { createFolder } from "../commands/commands";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { editEnvironment } from "../lib/editEnvironment";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import {
|
||||
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
action: "settings.show",
|
||||
onSelect: () => openSettings.mutate(null),
|
||||
},
|
||||
{
|
||||
key: "workspace_settings.open",
|
||||
label: "Open Workspace Settings",
|
||||
action: "workspace_settings.show",
|
||||
onSelect: () => openWorkspaceSettings(),
|
||||
},
|
||||
{
|
||||
key: "app.create",
|
||||
label: "Create Workspace",
|
||||
@@ -127,13 +133,9 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
{
|
||||
key: "cookies.show",
|
||||
label: "Show Cookies",
|
||||
action: "cookies_editor.show",
|
||||
onSelect: async () => {
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||
});
|
||||
CookieDialog.show(activeCookieJar?.id ?? null);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -437,7 +439,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
name="command"
|
||||
label="Command"
|
||||
placeholder="Search or type a command"
|
||||
className="font-sans !text-base"
|
||||
className="font-sans text-base!"
|
||||
defaultValue={command}
|
||||
onChange={handleSetCommand}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
@@ -446,7 +448,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
|
||||
{filteredGroups.map((g) => (
|
||||
<div key={g.key} className="mb-1.5 w-full">
|
||||
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
||||
<Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center">
|
||||
{g.label}
|
||||
</Heading>
|
||||
{g.items.map((v) => (
|
||||
@@ -489,7 +491,7 @@ function CommandPaletteItem({
|
||||
color="custom"
|
||||
justify="start"
|
||||
className={classNames(
|
||||
"w-full h-sm flex items-center rounded px-1.5",
|
||||
"w-full h-sm flex items-center rounded-sm px-1.5",
|
||||
"hover:text-text",
|
||||
active && "bg-surface-highlight",
|
||||
!active && "text-text-subtle",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { pricingUrl } from "../lib/pricingUrl";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
|
||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
||||
"Personal use of Yaak is free. If you’re 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;
|
||||
}
|
||||
@@ -1,9 +1,40 @@
|
||||
import type { Cookie } from "@yaakapp-internal/models";
|
||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { formatDate } from "date-fns/format";
|
||||
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 { 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 { 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 {
|
||||
cookieJarId: string | null;
|
||||
@@ -12,56 +43,689 @@ interface Props {
|
||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
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) {
|
||||
return <div>No cookie jar selected</div>;
|
||||
}
|
||||
|
||||
if (cookieJar.cookies.length === 0) {
|
||||
return (
|
||||
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<PlainInput
|
||||
name="cookie-filter"
|
||||
label="Filter cookies"
|
||||
hideLabel
|
||||
placeholder="Filter cookies"
|
||||
defaultValue={filter}
|
||||
forceUpdateKey={filterUpdateKey}
|
||||
onChange={setFilter}
|
||||
rightSlot={
|
||||
filter.length > 0 && (
|
||||
<IconButton
|
||||
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||
icon="x"
|
||||
title="Clear filter"
|
||||
onClick={() => {
|
||||
setFilter("");
|
||||
setFilterUpdateKey((key) => key + 1);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
||||
</div>
|
||||
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
||||
<EmptyStateText>
|
||||
Cookies will appear when a response includes a Set-Cookie header.
|
||||
</EmptyStateText>
|
||||
) : filteredCookies.length === 0 && detailCookie == null ? (
|
||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||
) : (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
storageKey="cookie-dialog-details"
|
||||
defaultRatio={0.5}
|
||||
className="-mx-2"
|
||||
minHeightPx={10}
|
||||
firstSlot={({ style }) =>
|
||||
filteredCookies.length === 0 ? (
|
||||
<div style={style}>
|
||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable style={style} className="pr-0.5">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Value</TableHeaderCell>
|
||||
<TableHeaderCell>Domain</TableHeaderCell>
|
||||
<TableHeaderCell>Path</TableHeaderCell>
|
||||
<TableHeaderCell>Expires</TableHeaderCell>
|
||||
<TableHeaderCell>Size</TableHeaderCell>
|
||||
<TableHeaderCell>HTTP Only</TableHeaderCell>
|
||||
<TableHeaderCell>Secure</TableHeaderCell>
|
||||
<TableHeaderCell>Same Site</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<IconButton
|
||||
icon="list_x"
|
||||
size="sm"
|
||||
className="text-text-subtle"
|
||||
title="Clear all cookies"
|
||||
onClick={() => {
|
||||
setSelectedCookieKey(null);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
void patchModel(cookieJar, { cookies: [] });
|
||||
}}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
|
||||
{filteredCookies.map((c: Cookie) => {
|
||||
const key = cookieKey(c);
|
||||
const isSelected = key === selectedCookieKey;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={classNames(
|
||||
"group/tr cursor-default",
|
||||
isSelected && "[&_td]:bg-surface-highlight",
|
||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedCookieKey(key);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
}}
|
||||
>
|
||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
||||
{c.name}
|
||||
</TableCell>
|
||||
<TruncatedWideTableCell className="min-w-40">
|
||||
{c.value}
|
||||
</TruncatedWideTableCell>
|
||||
<TableCell>{cookieDomain(c)}</TableCell>
|
||||
<TableCell>{c.path}</TableCell>
|
||||
<TableCell>{cookieExpires(c)}</TableCell>
|
||||
<TableCell>{cookieSize(c)}</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={c.httpOnly ? "check" : "x"}
|
||||
className={classNames(!c.httpOnly && "opacity-10")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={c.secure ? "check" : "x"}
|
||||
className={classNames(!c.secure && "opacity-10")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{c.sameSite}</TableCell>
|
||||
<TableCell className="rounded-r pr-2">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isSelected) {
|
||||
setSelectedCookieKey(null);
|
||||
}
|
||||
if (editingCookieKey === key) {
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
}
|
||||
void patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter(
|
||||
(c2: Cookie) => cookieKey(c2) !== key,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
secondSlot={
|
||||
detailCookie == null
|
||||
? null
|
||||
: ({ style }) => (
|
||||
<CookieDetailsPane
|
||||
formRef={editorFormRef}
|
||||
isEditing={isEditingCookie}
|
||||
onSubmit={handleSaveCookie}
|
||||
style={style}
|
||||
>
|
||||
<EventDetailHeader
|
||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
||||
actions={
|
||||
isEditingCookie
|
||||
? [
|
||||
{
|
||||
key: "save",
|
||||
label: isCreatingCookie ? "Create" : "Save",
|
||||
onClick: () => editorFormRef.current?.requestSubmit(),
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
label: "Cancel",
|
||||
onClick: handleCancelEdit,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
onClick: handleEditCookie,
|
||||
},
|
||||
]
|
||||
}
|
||||
onClose={handleCloseDetails}
|
||||
/>
|
||||
{isEditingCookie ? (
|
||||
<CookieEditor
|
||||
cookie={detailCookie}
|
||||
expiresInputValue={draftExpiresInput}
|
||||
onChange={setDraftCookie}
|
||||
onExpiresInputChange={setDraftExpiresInput}
|
||||
/>
|
||||
) : (
|
||||
<CookieDetails cookie={detailCookie} />
|
||||
)}
|
||||
</CookieDetailsPane>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function CookieDetailsPane({
|
||||
children,
|
||||
formRef,
|
||||
isEditing,
|
||||
onSubmit,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
formRef: RefObject<HTMLFormElement | null>;
|
||||
isEditing: boolean;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
style: CSSProperties;
|
||||
}) {
|
||||
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Banner>
|
||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
||||
</Banner>
|
||||
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
<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 style={style} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CookieDialog.show = (cookieJarId: string | null) => {
|
||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
||||
if (cookieJar == null) {
|
||||
showAlert({
|
||||
id: "invalid-jar",
|
||||
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
||||
title: "Invalid Cookie Jar",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: `${cookieJar.name} Cookies`,
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
||||
});
|
||||
};
|
||||
|
||||
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<KeyValueRows selectable>
|
||||
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
||||
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
||||
{cookie.sameSite && (
|
||||
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
||||
)}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieEditor({
|
||||
cookie,
|
||||
expiresInputValue,
|
||||
onChange,
|
||||
onExpiresInputChange,
|
||||
}: {
|
||||
cookie: Cookie;
|
||||
expiresInputValue: string;
|
||||
onChange: (cookie: Cookie) => void;
|
||||
onExpiresInputChange: (value: string) => void;
|
||||
}) {
|
||||
const sessionCookie = cookie.expires === "SessionEnd";
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<KeyValueRows>
|
||||
<CookieKeyValueRow align="middle" label="Name">
|
||||
<CookieTextInput
|
||||
required
|
||||
autoFocus
|
||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||
value={cookie.name}
|
||||
onChange={(name) => onChange({ ...cookie, name })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Value">
|
||||
<CookieTextarea
|
||||
value={cookie.value}
|
||||
onChange={(value) => onChange({ ...cookie, value })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Domain">
|
||||
<CookieTextInput
|
||||
required
|
||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||
value={cookieDomainInputValue(cookie)}
|
||||
placeholder="example.com"
|
||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Path">
|
||||
<CookieTextInput
|
||||
value={cookie.path}
|
||||
placeholder="/"
|
||||
onChange={(path) => onChange({ ...cookie, path })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Expires">
|
||||
<div className="grid gap-1">
|
||||
<Checkbox
|
||||
checked={sessionCookie}
|
||||
title="Session cookie"
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange({ ...cookie, expires: "SessionEnd" });
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresInput =
|
||||
cookieExpiresFromInput(expiresInputValue) == null
|
||||
? defaultCookieExpiresInputValue()
|
||||
: expiresInputValue;
|
||||
|
||||
onExpiresInputChange(expiresInput);
|
||||
onChange({
|
||||
...cookie,
|
||||
expires: cookieExpiresFromInput(expiresInput)!,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CookieTextInput
|
||||
value={sessionCookie ? "" : expiresInputValue}
|
||||
disabled={sessionCookie}
|
||||
onChange={(value) => {
|
||||
onExpiresInputChange(value);
|
||||
|
||||
const expires = cookieExpiresFromInput(value);
|
||||
if (expires != null) {
|
||||
onChange({ ...cookie, expires });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="HTTP Only">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="HTTP Only"
|
||||
checked={cookie.httpOnly}
|
||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Secure">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="Secure"
|
||||
checked={cookie.secure}
|
||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Same Site">
|
||||
<Select
|
||||
hideLabel
|
||||
name="cookie-same-site"
|
||||
label="Same Site"
|
||||
value={cookie.sameSite ?? ""}
|
||||
size="xs"
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "n/a", value: "" },
|
||||
{ label: "Lax", value: "Lax" },
|
||||
{ label: "Strict", value: "Strict" },
|
||||
{ label: "None", value: "None" },
|
||||
]}
|
||||
onChange={(sameSite) =>
|
||||
onChange({
|
||||
...cookie,
|
||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
||||
return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
|
||||
}
|
||||
|
||||
function CookieTextInput({
|
||||
autoFocus,
|
||||
disabled,
|
||||
onChange,
|
||||
pattern,
|
||||
placeholder,
|
||||
required,
|
||||
value,
|
||||
}: {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cookieInputClassName}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
pattern={pattern}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||
return (
|
||||
<textarea
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={classNames(cookieInputClassName, "min-h-20 resize-y")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
||||
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
|
||||
const cookieInputClassName = classNames(
|
||||
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
|
||||
"border border-border-subtle outline-hidden",
|
||||
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
|
||||
"focus:border-border-focus invalid:border-danger",
|
||||
"disabled:opacity-disabled disabled:border-dotted",
|
||||
);
|
||||
|
||||
function cookieSize(cookie: Cookie) {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
||||
}
|
||||
|
||||
function newCookieDraft(): Cookie {
|
||||
return {
|
||||
name: "",
|
||||
value: "",
|
||||
domain: "NotPresent",
|
||||
expires: "SessionEnd",
|
||||
path: "/",
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
sameSite: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCookie(cookie: Cookie): Cookie {
|
||||
return {
|
||||
...cookie,
|
||||
domain: normalizeCookieDomain(cookie.domain),
|
||||
name: cookie.name.trim(),
|
||||
path: cookie.path.trim() || "/",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
|
||||
if (domain === "NotPresent" || domain === "Empty") {
|
||||
return domain;
|
||||
}
|
||||
|
||||
if ("Suffix" in domain) {
|
||||
return { Suffix: domain.Suffix.trim() };
|
||||
}
|
||||
|
||||
return { HostOnly: domain.HostOnly.trim() };
|
||||
}
|
||||
|
||||
function cookieDomainInputValue(cookie: Cookie) {
|
||||
const domain = cookieDomain(cookie);
|
||||
return domain === "n/a" ? "" : domain;
|
||||
}
|
||||
|
||||
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
||||
const trimmedDomain = domain.trim();
|
||||
if (trimmedDomain.length === 0) {
|
||||
return { ...cookie, domain: "NotPresent" };
|
||||
}
|
||||
|
||||
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
||||
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
||||
}
|
||||
|
||||
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
||||
}
|
||||
|
||||
function cookieExpires(cookie: Cookie) {
|
||||
if (cookie.expires === "SessionEnd") {
|
||||
return "Session";
|
||||
}
|
||||
|
||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||
if (!Number.isFinite(expiresSeconds)) {
|
||||
return cookie.expires.AtUtc;
|
||||
}
|
||||
|
||||
const date = new Date(expiresSeconds * 1000);
|
||||
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
||||
}
|
||||
|
||||
function cookieExpiresInputValue(cookie: Cookie) {
|
||||
if (cookie.expires === "SessionEnd") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||
if (!Number.isFinite(expiresSeconds)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Date(expiresSeconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
function defaultCookieExpiresInputValue() {
|
||||
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { AtUtc: `${Math.floor(time / 1000)}` };
|
||||
}
|
||||
|
||||
function cookieMatchesFilter(cookie: Cookie, filter: string) {
|
||||
const query = filter.trim().toLowerCase();
|
||||
if (query.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
function cookieKey(cookie: Cookie) {
|
||||
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
|
||||
}
|
||||
|
||||
function cookieDomainKey(domain: Cookie["domain"]) {
|
||||
if (typeof domain !== "string" && "HostOnly" in domain) {
|
||||
return `HostOnly:${domain.HostOnly}`;
|
||||
}
|
||||
|
||||
if (typeof domain !== "string" && "Suffix" in domain) {
|
||||
return `Suffix:${domain.Suffix}`;
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
|
||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
||||
leftSlot: <Icon icon="cookie" />,
|
||||
onSelect: () => {
|
||||
if (activeCookieJar == null) return;
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
||||
});
|
||||
CookieDialog.show(activeCookieJar.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DnsOverridesEditor({ workspace }: Props) {
|
||||
<VStack space={3} className="pb-3">
|
||||
<div className="text-text-subtle text-sm">
|
||||
Override DNS resolution for specific hostnames. This works like{" "}
|
||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
|
||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
|
||||
only for requests made from this workspace.
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ export const DropMarker = memo(
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute bg-primary rounded-full",
|
||||
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
|
||||
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
|
||||
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
|
||||
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
<div key={i + stateKey}>
|
||||
<DetailsBanner
|
||||
summary={input.label}
|
||||
className={classNames("!mb-auto", disabled && "opacity-disabled")}
|
||||
className={classNames("mb-auto!", disabled && "opacity-disabled")}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<FormInputsStack
|
||||
@@ -300,7 +300,7 @@ function TextArg({
|
||||
onChange,
|
||||
name: arg.name,
|
||||
multiLine: arg.multiLine,
|
||||
className: arg.multiLine ? "min-h-[4rem]" : undefined,
|
||||
className: arg.multiLine ? "min-h-16" : undefined,
|
||||
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
||||
required: !arg.optional,
|
||||
disabled: arg.disabled,
|
||||
@@ -359,7 +359,7 @@ function EditorArg({
|
||||
className={classNames(
|
||||
"border border-border rounded-md overflow-hidden px-2 py-1",
|
||||
"focus-within:border-border-focus",
|
||||
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
|
||||
!arg.rows && "max-h-40", // So it doesn't take up too much space
|
||||
)}
|
||||
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
|
||||
>
|
||||
@@ -372,7 +372,7 @@ function EditorArg({
|
||||
onChange={onChange}
|
||||
hideGutter
|
||||
heightMode="auto"
|
||||
className="min-h-[3rem]"
|
||||
className="min-h-12"
|
||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
||||
placeholder={arg.placeholder ?? undefined}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
@@ -392,7 +392,7 @@ function EditorArg({
|
||||
id: "id",
|
||||
size: "full",
|
||||
title: arg.readOnly ? "View Value" : "Edit Value",
|
||||
className: "!max-w-[50rem] !max-h-[60rem]",
|
||||
className: "max-w-200! max-h-240!",
|
||||
description: arg.label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function EmptyStateText({ children, className }: Props) {
|
||||
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
|
||||
return (
|
||||
<div className="w-full h-full pb-2">
|
||||
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
|
||||
@@ -62,7 +62,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
"text !px-2 truncate",
|
||||
"text px-2! truncate",
|
||||
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
|
||||
)}
|
||||
// If no environments, the button simply opens the dialog.
|
||||
|
||||
@@ -57,7 +57,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
defaultRatio={0.75}
|
||||
layout="horizontal"
|
||||
className="gap-0"
|
||||
resizeHandleClassName="-translate-x-[1px]"
|
||||
resizeHandleClassName="-translate-x-px"
|
||||
firstSlot={() => (
|
||||
<EnvironmentEditDialogSidebar
|
||||
selectedEnvironmentId={selectedEnvironment?.id ?? null}
|
||||
|
||||
@@ -8,6 +8,7 @@ import slugify from "slugify";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
@@ -85,8 +86,10 @@ function ExportDataDialogContent({
|
||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||
const noneSelected = numSelected === 0;
|
||||
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">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -137,9 +140,9 @@ function ExportDataDialogContent({
|
||||
/>
|
||||
</DetailsBanner>
|
||||
</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>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
||||
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
||||
<code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
|
||||
{request.method} {request.url}
|
||||
</code>
|
||||
{latestResponse ? (
|
||||
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
className={classNames(
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
|
||||
"font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
|
||||
)}
|
||||
>
|
||||
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
folderId: string | null;
|
||||
@@ -29,6 +30,7 @@ interface Props {
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_VARIABLES = "variables";
|
||||
const TAB_GENERAL = "general";
|
||||
|
||||
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_SETTINGS
|
||||
| typeof TAB_VARIABLES;
|
||||
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
||||
);
|
||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
if (folder == null) return [];
|
||||
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
value: TAB_GENERAL,
|
||||
label: "General",
|
||||
},
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
@@ -68,19 +77,19 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||
},
|
||||
];
|
||||
}, [authTab, folder, headersTab, numVars]);
|
||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
||||
|
||||
if (folder == null) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
|
||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{index > 0 && (
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
|
||||
)}
|
||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
||||
{item.name}
|
||||
@@ -88,7 +97,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
</Fragment>
|
||||
))}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
|
||||
)}
|
||||
<span className="whitespace-nowrap" title={folder.name}>
|
||||
{folder.name}
|
||||
@@ -140,7 +149,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
||||
{folder.id}
|
||||
<CopyIconButton
|
||||
className="opacity-70 !text-primary"
|
||||
className="opacity-70 text-primary!"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Copy folder ID"
|
||||
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
</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">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function GrpcEditor({
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && "!opacity-100")}>
|
||||
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
|
||||
@@ -20,6 +20,7 @@ import { GrpcEditor } from "./GrpcEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
|
||||
interface Props {
|
||||
@@ -47,6 +48,7 @@ interface Props {
|
||||
const TAB_MESSAGE = "message";
|
||||
const TAB_METADATA = "metadata";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
|
||||
export function GrpcRequestPane({
|
||||
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const numSettingsOverrides = countOverriddenSettings(activeRequest);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
|
||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
||||
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
|
||||
{ value: TAB_MESSAGE, label: "Message" },
|
||||
...metadataTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab],
|
||||
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
@@ -154,7 +162,7 @@ export function GrpcRequestPane({
|
||||
className={classNames(
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
|
||||
paneWidth === 0 && "opacity-0",
|
||||
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
|
||||
paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
|
||||
)}
|
||||
>
|
||||
<UrlBar
|
||||
@@ -193,7 +201,7 @@ export function GrpcRequestPane({
|
||||
rightSlot={<Icon size="sm" icon="chevron_down" />}
|
||||
disabled={isStreaming || services == null}
|
||||
className={classNames(
|
||||
"font-mono text-editor min-w-[5rem] !ring-0",
|
||||
"font-mono text-editor min-w-20 ring-0!",
|
||||
paneWidth < 400 && "flex-1",
|
||||
)}
|
||||
>
|
||||
@@ -251,7 +259,7 @@ export function GrpcRequestPane({
|
||||
<Tabs
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
tabListClassName="mt-1 mb-1.5!"
|
||||
storageKey="grpc_request_tabs"
|
||||
activeTabKey={activeRequest.id}
|
||||
>
|
||||
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
|
||||
onChange={handleMetadataChange}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
@@ -285,7 +296,7 @@ export function GrpcRequestPane({
|
||||
hideLabel
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans !text-xl !px-0"
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
|
||||
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
|
||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Input, type InputProps } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
||||
async (authentication: Record<string, unknown>) =>
|
||||
await patchModel(model, { authentication }),
|
||||
[model],
|
||||
);
|
||||
|
||||
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
||||
Auth plugin not found for{" "}
|
||||
<InlineCode>{model.authenticationType}</InlineCode>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
if (inheritedAuth == null) {
|
||||
if (model.model === "workspace" || model.model === "folder") {
|
||||
return (
|
||||
<EmptyStateText className="flex-col gap-1">
|
||||
<p>
|
||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
||||
</p>
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||
<EmptyStateText className="flex-col gap-3">
|
||||
<div className="not-italic flex flex-col items-center gap-3 text-center">
|
||||
<p className="max-w-md text-sm text-text-subtle">
|
||||
Choose an auth method to apply it to all requests in{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
type="submit"
|
||||
className="underline hover:text-text"
|
||||
onClick={() => {
|
||||
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
||||
if (inheritedAuth.model === "folder")
|
||||
openFolderSettings(inheritedAuth.id, "auth");
|
||||
else openWorkspaceSettings("auth");
|
||||
}}
|
||||
>
|
||||
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
hideLabel
|
||||
name="enabled"
|
||||
value={
|
||||
model.authentication.disabled === false || model.authentication.disabled == null
|
||||
model.authentication.disabled === false ||
|
||||
model.authentication.disabled == null
|
||||
? "__TRUE__"
|
||||
: model.authentication.disabled === true
|
||||
? "__FALSE__"
|
||||
@@ -140,7 +156,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
title="Authentication Actions"
|
||||
icon="settings"
|
||||
size="xs"
|
||||
className="!text-secondary"
|
||||
className="text-secondary!"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
className="w-full"
|
||||
stateKey={`auth.${model.id}.dynamic`}
|
||||
value={model.authentication.disabled}
|
||||
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
||||
onChange={(v) =>
|
||||
handleChange({ ...model.authentication, disabled: v })
|
||||
}
|
||||
/>
|
||||
</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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
|
||||
rightSlot={
|
||||
<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">
|
||||
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
||||
{rendered.isPending
|
||||
? "loading"
|
||||
: rendered.data
|
||||
? "enabled"
|
||||
: "disabled"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
|
||||
<GraphQLDocsExplorer
|
||||
requestId={activeRequest.id}
|
||||
schema={graphQLSchema}
|
||||
className={classNames(orientation === "horizontal" && "!ml-0")}
|
||||
className={classNames(orientation === "horizontal" && "ml-0!")}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -51,6 +52,7 @@ import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
|
||||
@@ -69,6 +71,7 @@ const TAB_BODY = "body";
|
||||
const TAB_PARAMS = "params";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
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 headersTab = useHeadersTab(TAB_HEADERS, 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)
|
||||
useRequestEditorEvent(
|
||||
@@ -128,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||
(m) => m[1] ?? "",
|
||||
);
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
@@ -234,6 +236,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
@@ -246,6 +253,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
handleContentTypeChange,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
urlParameterPairs.length,
|
||||
],
|
||||
);
|
||||
@@ -338,7 +346,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
@@ -372,6 +380,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
@@ -445,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans !text-xl !px-0"
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
|
||||
@@ -4,10 +4,12 @@ import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
||||
@@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
||||
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
@@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
itemsAfter: [
|
||||
{
|
||||
label: "Save to File",
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -135,12 +155,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
],
|
||||
[
|
||||
activeResponse?.headers,
|
||||
activeResponse,
|
||||
activeResponse?.error,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
saveResponse.mutate,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
@@ -167,7 +193,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full flex-shrink-0",
|
||||
"text-text-subtle w-full shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
@@ -180,7 +206,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full flex-shrink-0">
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
@@ -194,7 +220,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
@@ -223,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm !flex-shrink max-w-full"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
@@ -236,7 +262,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end flex-shrink-0">
|
||||
<div className="justify-self-end shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
@@ -249,7 +275,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { useAllRequests } from "../hooks/useAllRequests";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
@@ -95,6 +100,7 @@ function EventDetails({
|
||||
}) {
|
||||
const { label } = getEventDisplay(event.event);
|
||||
const e = event.event;
|
||||
const settingSourceModels = useSettingSourceModels();
|
||||
|
||||
const actions: EventDetailAction[] = [
|
||||
{
|
||||
@@ -211,6 +217,9 @@ function EventDetails({
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
{e.source_model != null ? (
|
||||
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
|
||||
) : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
@@ -315,6 +324,44 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
|
||||
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 = {
|
||||
icon: IconProps["icon"];
|
||||
color: IconProps["color"];
|
||||
@@ -325,11 +372,12 @@ type EventDisplay = {
|
||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||
switch (event.type) {
|
||||
case "setting":
|
||||
const sourceModel = formatSettingSourceModel(event);
|
||||
return {
|
||||
icon: "settings",
|
||||
color: "secondary",
|
||||
label: "Setting",
|
||||
summary: `${event.name} = ${event.value}`,
|
||||
summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
|
||||
};
|
||||
case "info":
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
@@ -14,6 +15,8 @@ export function ImportDataDialog({ importData }: Props) {
|
||||
|
||||
return (
|
||||
<VStack space={5} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<VStack space={1}>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>OpenAPI 3.0, 3.1</li>
|
||||
|
||||
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
||||
const actions = useMemo<EditorProps["actions"]>(
|
||||
() => [
|
||||
showBanner && (
|
||||
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
|
||||
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
|
||||
<p className="inline-flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">Auto-fix enabled</span>
|
||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||
</p>
|
||||
</Banner>
|
||||
),
|
||||
<div key="settings" className="!opacity-100 !shadow">
|
||||
<div key="settings" className="opacity-100! shadow!">
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
items={
|
||||
|
||||
@@ -59,7 +59,7 @@ function getDetail(
|
||||
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
|
||||
},
|
||||
{
|
||||
label: <div className="min-w-[12rem]">Renew License</div>,
|
||||
label: <div className="min-w-48">Renew License</div>,
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||
hidden: data.data.changesUrl == null,
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MarkdownEditor({
|
||||
<Editor
|
||||
hideGutter
|
||||
wrapLines
|
||||
className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")}
|
||||
className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")}
|
||||
language="markdown"
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
@@ -46,7 +46,7 @@ export function MarkdownEditor({
|
||||
defaultValue.length === 0 ? (
|
||||
<p className="text-text-subtlest">No description</p>
|
||||
) : (
|
||||
<div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
|
||||
<div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto">
|
||||
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
InheritedBoolSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
||||
import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
SETTING_STORE_COOKIES,
|
||||
SETTING_VALIDATE_CERTIFICATES,
|
||||
} from "../lib/requestSettings";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "./core/SettingRow";
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
|
||||
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
|
||||
|
||||
interface Props {
|
||||
showSectionTitles?: boolean;
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithTlsSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithCookieSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
type TlsSettingsPatch = {
|
||||
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
||||
};
|
||||
type MessageSizeSettingsPatch = {
|
||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||
};
|
||||
|
||||
export function ModelSettingsEditor({
|
||||
model,
|
||||
showSectionTitles = false,
|
||||
}: Props) {
|
||||
const ancestors = useModelAncestors(model);
|
||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
||||
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
|
||||
|
||||
return (
|
||||
<SettingsList className="space-y-8">
|
||||
{supportsTlsSettings && (
|
||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
||||
{supportsHttpSettings && (
|
||||
<IntegerSettingRow
|
||||
settingDefinition={SETTING_REQUEST_TIMEOUT}
|
||||
setting={model.settingRequestTimeout}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_REQUEST_TIMEOUT.modelKey,
|
||||
model.settingRequestTimeout,
|
||||
)}
|
||||
onChange={(settingRequestTimeout) =>
|
||||
patchHttpSettings(model, {
|
||||
settingRequestTimeout,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{supportsMessageSizeSettings && (
|
||||
<MessageSizeSettingRow
|
||||
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
|
||||
setting={model.settingRequestMessageSize}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
|
||||
model.settingRequestMessageSize,
|
||||
)}
|
||||
onChange={(settingRequestMessageSize) =>
|
||||
patchMessageSizeSettings(model, {
|
||||
settingRequestMessageSize,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
||||
setting={model.settingValidateCertificates}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_VALIDATE_CERTIFICATES.modelKey,
|
||||
model.settingValidateCertificates,
|
||||
)}
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchTlsSettings(model, {
|
||||
settingValidateCertificates,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{supportsHttpSettings && (
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_FOLLOW_REDIRECTS}
|
||||
setting={model.settingFollowRedirects}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_FOLLOW_REDIRECTS.modelKey,
|
||||
model.settingFollowRedirects,
|
||||
)}
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchHttpSettings(model, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection
|
||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||
>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_SEND_COOKIES.modelKey,
|
||||
model.settingSendCookies,
|
||||
)}
|
||||
onChange={(settingSendCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingSendCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_STORE_COOKIES}
|
||||
setting={model.settingStoreCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_STORE_COOKIES.modelKey,
|
||||
model.settingStoreCookies,
|
||||
)}
|
||||
onChange={(settingStoreCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingStoreCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
}
|
||||
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
}
|
||||
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter(
|
||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||
).length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(
|
||||
model: ModelWithCookieSettings,
|
||||
patch: Partial<CookieSettingsPatch>,
|
||||
) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchHttpSettings(
|
||||
model: ModelWithHttpSettings,
|
||||
patch: Partial<HttpSettingsPatch>,
|
||||
) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchTlsSettings(
|
||||
model: ModelWithTlsSettings,
|
||||
patch: Partial<TlsSettingsPatch>,
|
||||
) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
case "grpc_request":
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchMessageSizeSettings(
|
||||
model: ModelWithMessageSizeSettings,
|
||||
patch: Partial<MessageSizeSettingsPatch>,
|
||||
) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
case "grpc_request":
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithHttpSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
function modelSupportsCookieSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithCookieSettings {
|
||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||
}
|
||||
|
||||
function modelSupportsTlsSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithTlsSettings {
|
||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||
}
|
||||
|
||||
function modelSupportsMessageSizeSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithMessageSizeSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
|
||||
}
|
||||
|
||||
function BooleanSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: boolean;
|
||||
setting: BooleanSetting;
|
||||
settingDefinition: RequestSettingDefinition;
|
||||
onChange: (setting: BooleanSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRowBoolean
|
||||
checked={value}
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="md"
|
||||
title={settingDefinition.title}
|
||||
checked={value}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegerSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: number;
|
||||
setting: IntegerSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
|
||||
onChange: (setting: IntegerSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
unit="ms"
|
||||
value={`${value}`}
|
||||
placeholder={`${settingDefinition.defaultValue}`}
|
||||
validate={isValidInteger}
|
||||
onChange={(value) => onChange(parseInteger(value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
unit="ms"
|
||||
value={`${value}`}
|
||||
placeholder={`${settingDefinition.defaultValue}`}
|
||||
validate={isValidInteger}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...setting,
|
||||
enabled: true,
|
||||
value: parseInteger(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageSizeSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: number;
|
||||
setting: IntegerSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
|
||||
onChange: (setting: IntegerSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const displayValue = formatMegabytes(value);
|
||||
const placeholder = "0";
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) => onChange(parseMegabytes(value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...setting,
|
||||
enabled: true,
|
||||
value: parseMegabytes(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageSizeInput({
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<NumberUnitInput
|
||||
name={name}
|
||||
label={label}
|
||||
unit="MB"
|
||||
value={value}
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
placeholder={placeholder}
|
||||
validate={isValidMegabytes}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberUnitInput({
|
||||
inputMode,
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
step,
|
||||
unit,
|
||||
validate,
|
||||
value,
|
||||
}: {
|
||||
inputMode?: "decimal" | "numeric";
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
step?: number | "any";
|
||||
unit: string;
|
||||
validate: (value: string) => boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<PlainInput
|
||||
hideLabel
|
||||
name={name}
|
||||
label={label}
|
||||
size="sm"
|
||||
type="number"
|
||||
inputMode={inputMode}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
containerClassName="w-48!"
|
||||
validate={validate}
|
||||
rightSlot={
|
||||
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
|
||||
{unit}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isInheritedSetting<T>(
|
||||
setting: T | { enabled?: boolean; value: T },
|
||||
): setting is { enabled?: boolean; value: T } {
|
||||
return typeof setting === "object" && setting != null && "value" in setting;
|
||||
}
|
||||
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingRequestTimeout" | "settingRequestMessageSize",
|
||||
fallback: IntegerSetting,
|
||||
): number;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
||||
}
|
||||
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "settingValidateCertificates"
|
||||
>;
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||
>;
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / BYTES_PER_MB;
|
||||
return Number.isInteger(megabytes)
|
||||
? `${megabytes}`
|
||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseMegabytes(value: string) {
|
||||
const megabytes = Number(value);
|
||||
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
||||
}
|
||||
|
||||
function isValidInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
|
||||
}
|
||||
|
||||
function isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return (
|
||||
Number.isFinite(megabytes) &&
|
||||
megabytes >= 0 &&
|
||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
|
||||
<Button
|
||||
size="xs"
|
||||
color="secondary"
|
||||
className="mr-auto min-w-[5rem]"
|
||||
className="mr-auto min-w-20"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@reference "../main.css";
|
||||
|
||||
.prose {
|
||||
@apply text-text;
|
||||
|
||||
@@ -98,7 +100,7 @@
|
||||
@apply text-notice hover:underline;
|
||||
|
||||
* {
|
||||
@apply text-notice !important;
|
||||
@apply text-notice!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,12 +115,12 @@
|
||||
ol code,
|
||||
ul code {
|
||||
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
|
||||
@apply px-1.5 py-0.5 rounded not-italic;
|
||||
@apply px-1.5 py-0.5 rounded-sm not-italic;
|
||||
@apply select-text;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-surface-highlight text-text !important;
|
||||
@apply bg-surface-highlight! text-text!;
|
||||
@apply px-4 py-3 rounded-md;
|
||||
@apply overflow-auto whitespace-pre;
|
||||
@apply text-editor font-mono;
|
||||
@@ -130,7 +132,7 @@
|
||||
|
||||
.banner {
|
||||
@apply border border-dashed;
|
||||
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;
|
||||
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base;
|
||||
|
||||
&::before {
|
||||
@apply block font-bold mb-1;
|
||||
@@ -161,7 +163,7 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;
|
||||
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg;
|
||||
|
||||
p {
|
||||
@apply m-0;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({
|
||||
}: Props) {
|
||||
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
|
||||
const latestConnectionId = connections[0]?.id ?? "n/a";
|
||||
const connectionHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentConnections = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const c of connections) {
|
||||
const createdAt = `${c.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentConnections = true;
|
||||
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
connectionHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
connectionHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<span className="font-mono">{formatMillis(c.elapsed)}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -36,16 +100,7 @@ export function RecentGrpcConnectionsDropdown({
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: "separator", label: "History" },
|
||||
...connections.map((c) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{" "}
|
||||
<span className="font-mono text-sm">{c.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
})),
|
||||
...connectionHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
@@ -22,32 +30,93 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
|
||||
const { value: dismissedMovedActions } = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", movedActionsBannerId],
|
||||
fallback: false,
|
||||
});
|
||||
const latestResponseId = responses[0]?.id ?? "n/a";
|
||||
const saveResponse = useSaveResponse(activeResponse);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse);
|
||||
const responseHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentResponses = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const r of responses) {
|
||||
const createdAt = `${r.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentResponses = true;
|
||||
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
|
||||
responseHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
responseHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
responseHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<HttpStatusTag short className="text-xs" response={r} />
|
||||
<span className="text-text-subtlest">•</span>
|
||||
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
|
||||
<span className="text-text-subtlest">•</span>
|
||||
<SizeTag
|
||||
className="text-xs"
|
||||
contentLength={r.contentLength ?? 0}
|
||||
contentLengthCompressed={r.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedResponseId(r.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentResponses && !hasShownRecentEmptyState) {
|
||||
responseHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Save to File",
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => deleteModel(activeResponse),
|
||||
},
|
||||
{
|
||||
label: "Delete all",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: "Unpin Response",
|
||||
onSelect: () => onPinnedResponseId(activeResponse.id),
|
||||
@@ -55,25 +124,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: "separator", label: "History" },
|
||||
{
|
||||
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length === 0,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: "separator" },
|
||||
...responses.map((r: HttpResponse) => ({
|
||||
type: "content",
|
||||
hidden: dismissedMovedActions === true,
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<HttpStatusTag short className="text-xs" response={r} />
|
||||
<span className="text-text-subtle">→</span>{" "}
|
||||
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span>
|
||||
</HStack>
|
||||
<DismissibleBanner
|
||||
id={movedActionsBannerId}
|
||||
color="info"
|
||||
size="xs"
|
||||
className="max-w-72"
|
||||
>
|
||||
<p>Copy and save actions moved to the Response tab menu.</p>
|
||||
</DismissibleBanner>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedResponseId(r.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
label: "Recent",
|
||||
},
|
||||
...responseHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +26,63 @@ export function RecentWebsocketConnectionsDropdown({
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const latestConnectionId = connections[0]?.id ?? "n/a";
|
||||
const connectionHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentConnections = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const c of connections) {
|
||||
const createdAt = `${c.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentConnections = true;
|
||||
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
connectionHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
connectionHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<span className="font-mono">{formatMillis(c.elapsed)}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -40,16 +104,7 @@ export function RecentWebsocketConnectionsDropdown({
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: "separator", label: "History" },
|
||||
...connections.map((c) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{" "}
|
||||
<span className="font-mono text-sm">{c.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
})),
|
||||
...connectionHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ResponseCookies({ response }: Props) {
|
||||
{cookie.value}
|
||||
</span>
|
||||
{cookie.isDeleted && (
|
||||
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
|
||||
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
|
||||
Deleted
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
@@ -29,12 +30,20 @@ export function ResponseHeaders({ response }: Props) {
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="secondary" label="Sent">
|
||||
<time
|
||||
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
|
||||
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
|
||||
>
|
||||
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
|
||||
</time>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
|
||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ResponseInfo({ response }: Props) {
|
||||
URL
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100"
|
||||
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function RouteError({ error }: { error: unknown }) {
|
||||
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<VStack space={5} className="w-[50rem] !h-auto">
|
||||
<VStack space={5} className="w-200 h-auto!">
|
||||
<Heading>Route Error 🔥</Heading>
|
||||
<FormattedError>
|
||||
{message}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
|
||||
inline?: boolean;
|
||||
noun?: string;
|
||||
help?: ReactNode;
|
||||
hideLabel?: boolean;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -36,6 +37,7 @@ export function SelectFile({
|
||||
size = "sm",
|
||||
label,
|
||||
help,
|
||||
hideLabel,
|
||||
...props
|
||||
}: Props) {
|
||||
const handleClick = async () => {
|
||||
@@ -95,7 +97,7 @@ export function SelectFile({
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{label && (
|
||||
<Label htmlFor={null} help={help}>
|
||||
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
@@ -106,7 +108,7 @@ export function SelectFile({
|
||||
"rtl mr-1.5",
|
||||
inline && "w-full",
|
||||
filePath && inline && "font-mono text-xs",
|
||||
isHovering && "!border-notice",
|
||||
isHovering && "border-notice!",
|
||||
)}
|
||||
color={isHovering ? "primary" : "secondary"}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function Settings({ hide }: Props) {
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
tabs={tabs.map(
|
||||
(value): TabItem => ({
|
||||
@@ -131,28 +131,28 @@ export default function Settings({ hide }: Props) {
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsGeneral />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsInterface />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsTheme />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsHotkeys />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsProxy />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsCertificates />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsLicense />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { DetailsBanner } from "../core/DetailsBanner";
|
||||
@@ -232,6 +233,8 @@ export function SettingsCertificates() {
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
|
||||
@@ -2,174 +2,168 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Select } from "../core/Select";
|
||||
import { Separator } from "../core/Separator";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingSelectControl,
|
||||
SettingValue,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorkspaceSettingsMovedBanner =
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-4">
|
||||
<div>
|
||||
<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>
|
||||
<CargoFeature feature="updater">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||
<Select
|
||||
name="updateChannel"
|
||||
label="Update Channel"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[14rem]"
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||
options={[
|
||||
{ label: "Stable", value: "stable" },
|
||||
{ label: "Beta (more frequent)", value: "beta" },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 mb-5">
|
||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<CargoFeature feature="updater">
|
||||
<SettingsSection title="Updates">
|
||||
<SettingRow
|
||||
title="Update Channel"
|
||||
description="Choose whether Yaak should use stable releases or beta releases."
|
||||
>
|
||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
||||
<ModelSettingSelectControl
|
||||
model={settings}
|
||||
modelKey="updateChannel"
|
||||
label="Update Channel"
|
||||
selectClassName="w-full!"
|
||||
options={[
|
||||
{ label: "Stable", value: "stable" },
|
||||
{ label: "Beta", value: "beta" },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<Select
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? "auto" : "manual"}
|
||||
label="Update Behavior"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
labelClassName="w-[14rem]"
|
||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
||||
options={[
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ 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)}
|
||||
<SettingRowSelect
|
||||
title="Update Behavior"
|
||||
description="Choose whether updates are installed automatically or manually."
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? "auto" : "manual"}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { autoupdate: v === "auto" })
|
||||
}
|
||||
options={[
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "Manual", value: "manual" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appDataDir}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Logs Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="autoDownloadUpdates"
|
||||
title="Automatically download updates"
|
||||
description="Download Yaak updates in the background so they are ready to install."
|
||||
disabled={!settings.autoupdate}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appLogDir}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="checkNotifications"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps
|
||||
}}
|
||||
className={classNames(
|
||||
"flex items-center justify-center",
|
||||
"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full",
|
||||
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
|
||||
"border-border-subtle focus:border-border-focus",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,17 +3,27 @@ import { useFonts } from "@yaakapp-internal/fonts";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { EditorKeymap, Settings } 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 { useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { invokeCmd } from "../../lib/tauri";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
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__";
|
||||
|
||||
@@ -38,154 +48,172 @@ export function SettingsInterface() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<Select
|
||||
name="switchWorkspaceBehavior"
|
||||
label="Open workspace behavior"
|
||||
size="sm"
|
||||
help="When opening a workspace, should it open in the current window or a new window?"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? "new"
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? "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,
|
||||
})) ?? []),
|
||||
]}
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
description="Choose what happens when opening another workspace."
|
||||
name="switchWorkspaceBehavior"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? "new"
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? "current"
|
||||
: "ask"
|
||||
}
|
||||
onChange={async (v) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
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={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
{ label: "Always ask", value: "ask" },
|
||||
{ label: "Open in current window", value: "current" },
|
||||
{ label: "Open in new window", value: "new" },
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { editorFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</SettingsSection>
|
||||
|
||||
<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" && (
|
||||
<Checkbox
|
||||
checked={settings.hideWindowControls}
|
||||
title="Hide window controls"
|
||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
title="Editor font"
|
||||
description="Font used in request and response editors."
|
||||
controlClassName="gap-1"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||
|
||||
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
|
||||
hideLabel
|
||||
size="md"
|
||||
checked={nativeTitlebar}
|
||||
title="Native title bar"
|
||||
help="Use the operating system's standard title bar and window controls"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="2xs"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await invokeCmd("cmd_restart");
|
||||
@@ -194,7 +222,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,37 +233,42 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-license-badge",
|
||||
title: "Confirm Personal Use",
|
||||
confirmText: "Confirm",
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{" "}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{" "}
|
||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: "Personal Use",
|
||||
color: "info",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return; // Cancel
|
||||
<SettingsSection title="License">
|
||||
<SettingRowBoolean
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
description="Hide the personal-use badge from the interface."
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-license-badge",
|
||||
title: "Confirm Personal Use",
|
||||
confirmText: "Confirm",
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{" "}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{" "}
|
||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
||||
Purchase a License →
|
||||
</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
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 { useToggle } from "../../hooks/useToggle";
|
||||
import { pluralizeCount } from "../../lib/pluralize";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Link } from "../core/Link";
|
||||
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
|
||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||
<Separator className="my-2" />
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
|
||||
</span>
|
||||
<Separator className="my-2" />
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
|
||||
<Button
|
||||
color="secondary"
|
||||
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" />}
|
||||
>
|
||||
Direct Support
|
||||
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
|
||||
color="primary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={() =>
|
||||
openUrl(
|
||||
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
|
||||
)
|
||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
||||
}
|
||||
>
|
||||
Purchase License
|
||||
|
||||
@@ -211,7 +211,7 @@ function PluginTableRow({
|
||||
return (
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableCell className="!py-0">
|
||||
<TableCell className="py-0!">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
|
||||
@@ -249,7 +249,7 @@ function PluginTableRow({
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell className="!py-0">
|
||||
<TableCell className="py-0!">
|
||||
<HStack justifyContent="end" space={1.5}>
|
||||
{plugin != null && latestVersion != null ? (
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
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 { Checkbox } from "../core/Checkbox";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Select } from "../core/Select";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import {
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
export function SettingsProxy() {
|
||||
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 (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
@@ -18,188 +34,147 @@ export function SettingsProxy() {
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="proxy"
|
||||
label="Proxy"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={settings.proxy?.type ?? "automatic"}
|
||||
onChange={async (v) => {
|
||||
if (v === "automatic") {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === "enabled") {
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
disabled: false,
|
||||
type: "enabled",
|
||||
http: "",
|
||||
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
|
||||
</>
|
||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<SettingRowSelect
|
||||
title="Proxy"
|
||||
description="Choose how Yaak should discover or use proxy settings."
|
||||
name="proxy"
|
||||
value={settings.proxy?.type ?? "automatic"}
|
||||
onChange={async (v) => {
|
||||
if (v === "automatic") {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === "enabled") {
|
||||
await patchModel(settings, { proxy });
|
||||
} else {
|
||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
||||
}
|
||||
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 && (
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
label="User"
|
||||
placeholder="myUser"
|
||||
defaultValue={settings.proxy.auth.user}
|
||||
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 },
|
||||
});
|
||||
}}
|
||||
{settings.proxy?.type === "enabled" && (
|
||||
<>
|
||||
<SettingsSection title="Custom Proxy">
|
||||
<SettingRowBoolean
|
||||
checked={!settings.proxy.disabled}
|
||||
title="Enable proxy"
|
||||
description="Temporarily disable the proxy without losing the configuration."
|
||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="s3cretPassw0rd"
|
||||
defaultValue={settings.proxy.auth.password}
|
||||
onChange={async (password) => {
|
||||
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 user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
<SettingRowText
|
||||
name="proxyHttp"
|
||||
title={
|
||||
<>
|
||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
description="Proxy host used for unencrypted HTTP traffic."
|
||||
value={settings.proxy.http}
|
||||
placeholder="localhost:9090"
|
||||
onChange={(http) => patchProxy({ http })}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
{settings.proxy.type === "enabled" && (
|
||||
<>
|
||||
<Separator className="my-6" />
|
||||
<PlainInput
|
||||
label="Proxy Bypass"
|
||||
help="Comma-separated list to bypass the proxy."
|
||||
defaultValue={settings.proxy.bypass}
|
||||
<SettingRowText
|
||||
name="proxyHttps"
|
||||
title={
|
||||
<>
|
||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
description="Proxy host used for HTTPS traffic."
|
||||
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"
|
||||
onChange={async (bypass) => {
|
||||
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 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 },
|
||||
});
|
||||
}}
|
||||
inputWidthClassName="w-96!"
|
||||
onChange={(bypass) => patchProxy({ bypass })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { Link } from "../core/Link";
|
||||
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 })));
|
||||
|
||||
@@ -67,7 +72,7 @@ export function SettingsTheme() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
@@ -77,96 +82,92 @@ export function SettingsTheme() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="appearance"
|
||||
label="Appearance"
|
||||
labelPosition="top"
|
||||
size="sm"
|
||||
value={settings.appearance}
|
||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
||||
options={[
|
||||
{ label: "Automatic", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ 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 })}
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Theme">
|
||||
<ModelSettingRowSelect
|
||||
model={settings}
|
||||
modelKey="appearance"
|
||||
title="Appearance"
|
||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
||||
options={[
|
||||
{ label: "Automatic", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||
<Select
|
||||
hideLabel
|
||||
name="darkTheme"
|
||||
className="flex-1"
|
||||
label="Dark Theme"
|
||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||
size="sm"
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
||||
<SettingRowSelect
|
||||
name="lightTheme"
|
||||
title="Light theme"
|
||||
description="Theme used when Yaak is in light mode."
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||
<SettingRowSelect
|
||||
name="darkTheme"
|
||||
title="Dark theme"
|
||||
description="Theme used when Yaak is in dark mode."
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-3 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"} />
|
||||
<strong>{activeTheme.data.active.label}</strong>
|
||||
<em>(preview)</em>
|
||||
</HStack>
|
||||
<HStack space={1.5} className="w-full">
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
variant="border"
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<Suspense>
|
||||
<Editor
|
||||
defaultValue={[
|
||||
"let foo = { // Demo code editor",
|
||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||
" baz: [1, 10.2, null, false, true],",
|
||||
"};",
|
||||
].join("\n")}
|
||||
heightMode="auto"
|
||||
language="javascript"
|
||||
stateKey={null}
|
||||
/>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
<SettingsSection title="Preview">
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
|
||||
>
|
||||
<HStack className="text" space={1.5}>
|
||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
||||
<strong>{activeTheme.data.active.label}</strong>
|
||||
<em>(preview)</em>
|
||||
</HStack>
|
||||
<HStack space={1.5} className="w-full">
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
variant="border"
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<Suspense>
|
||||
<Editor
|
||||
defaultValue={[
|
||||
"let foo = { // Demo code editor",
|
||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||
" baz: [1, 10.2, null, false, true],",
|
||||
"};",
|
||||
].join("\n")}
|
||||
heightMode="auto"
|
||||
language="javascript"
|
||||
stateKey={null}
|
||||
/>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import { pricingUrl } from "../lib/pricingUrl";
|
||||
import type { DropdownRef } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
|
||||
hidden: check.data == null || check.data.status === "active",
|
||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||
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",
|
||||
|
||||
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||
import type { FieldDef } 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 { formatFieldFilter } from "./core/Editor/filter/format";
|
||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import {
|
||||
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { InputHandle } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||
import { GitDropdown } from "./git/GitDropdown";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||
const filterText = useAtomValue(sidebarFilterAtom);
|
||||
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const filterRef = useRef<InputHandle>(null);
|
||||
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
|
||||
);
|
||||
|
||||
const clearFilterText = useCallback(() => {
|
||||
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
|
||||
setSidebarFilterText("");
|
||||
requestAnimationFrame(() => {
|
||||
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 getSelectedTreeModels = useCallback(
|
||||
@@ -578,7 +588,7 @@ function Sidebar({ className }: { className?: string }) {
|
||||
rightSlot={
|
||||
filterText.text && (
|
||||
<IconButton
|
||||
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||
icon="x"
|
||||
title="Clear filter"
|
||||
onClick={clearFilterText}
|
||||
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
|
||||
)}
|
||||
</div>
|
||||
{allHidden ? (
|
||||
<div className="italic text-text-subtle p-3 text-sm text-center">
|
||||
No results for <InlineCode>{filterText.text}</InlineCode>
|
||||
<div className="p-3 text-sm text-center">
|
||||
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
|
||||
<EmptyStateText
|
||||
wrapperClassName="h-auto! mb-auto"
|
||||
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
|
||||
>
|
||||
<div>
|
||||
No results, but found matches for{" "}
|
||||
{emptyFilterSuggestions?.map((suggestion, i) => (
|
||||
<span key={suggestion.field}>
|
||||
{i > 0 && " or "}
|
||||
<button
|
||||
type="button"
|
||||
className="max-w-full rounded-sm align-middle focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-info"
|
||||
onClick={() => applyFilterExample(suggestion.filterText)}
|
||||
>
|
||||
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
|
||||
{suggestion.filterText}
|
||||
</InlineCode>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<EmptyStateText
|
||||
wrapperClassName="h-auto! mb-auto"
|
||||
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
|
||||
>
|
||||
<div>
|
||||
No results for{" "}
|
||||
<InlineCode className="inline-block max-w-36 truncate align-middle">
|
||||
{filterText.text}
|
||||
</InlineCode>
|
||||
</div>
|
||||
</EmptyStateText>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tree
|
||||
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
|
||||
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 activeWorkspace = get(activeWorkspaceAtom);
|
||||
const filter = get(sidebarFilterAtom);
|
||||
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
||||
}
|
||||
|
||||
const queryAst = parseQuery(filter.text);
|
||||
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
||||
|
||||
// returns true if this node OR any child matches the filter
|
||||
const allFields: Record<string, Set<string>> = {};
|
||||
const suggestionFields = new Set<string>();
|
||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
let matchesSelf = true;
|
||||
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
||||
if (!value) continue;
|
||||
allFields[field] = allFields[field] ?? new Set();
|
||||
allFields[field].add(value);
|
||||
if (
|
||||
isLeafNode &&
|
||||
suggestionValue != null &&
|
||||
sidebarFieldMatchesValue(value, suggestionValue)
|
||||
) {
|
||||
suggestionFields.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (queryAst != null) {
|
||||
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
||||
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) => {
|
||||
|
||||
@@ -4,20 +4,79 @@ import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
export interface SyncToFilesystemSettingProps {
|
||||
layout?: "form" | "settings";
|
||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
||||
onCreateNewWorkspace: () => void;
|
||||
value: { filePath: string | null; initGit?: boolean };
|
||||
}
|
||||
|
||||
export function SyncToFilesystemSetting({
|
||||
layout = "form",
|
||||
onChange,
|
||||
onCreateNewWorkspace,
|
||||
value,
|
||||
}: SyncToFilesystemSettingProps) {
|
||||
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 (
|
||||
<VStack className="w-full my-2" space={3}>
|
||||
{syncDir && (
|
||||
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
|
||||
noun="Directory"
|
||||
help="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
onChange={async ({ filePath }) => {
|
||||
if (filePath != null) {
|
||||
const files = await readDir(filePath);
|
||||
if (files.length > 0) {
|
||||
setSyncDir(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSyncDir(null);
|
||||
onChange({ ...value, filePath });
|
||||
}}
|
||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
||||
/>
|
||||
|
||||
{value.filePath && typeof value.initGit === "boolean" && (
|
||||
|
||||
@@ -208,10 +208,10 @@ function InitializedTemplateFunctionDialog({
|
||||
)}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="relative w-full max-h-[10rem]">
|
||||
<div className="relative w-full max-h-40">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest",
|
||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||
tooLarge && "italic text-danger",
|
||||
)}
|
||||
>
|
||||
@@ -246,7 +246,7 @@ function InitializedTemplateFunctionDialog({
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
|
||||
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
|
||||
{templateFunction.data.name === "secure" && (
|
||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Reveal Encryption Key
|
||||
@@ -271,7 +271,7 @@ TemplateFunctionDialog.show = (
|
||||
showDialog({
|
||||
id: `template-function-${Math.random()}`, // Allow multiple at once
|
||||
size: "md",
|
||||
className: "h-[60rem]",
|
||||
className: "h-240",
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
|
||||
@@ -94,7 +94,7 @@ export const UrlBar = memo(function UrlBar({
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 !h-full"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
iconColor="secondary"
|
||||
icon={isLoading ? "x" : submitIcon}
|
||||
hotkeyAction="request.send"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
@@ -34,6 +35,7 @@ import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
|
||||
@@ -48,6 +50,7 @@ const TAB_MESSAGE = "message";
|
||||
const TAB_PARAMS = "params";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
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 headersTab = useHeadersTab(TAB_HEADERS, 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)
|
||||
useRequestEditorEvent(
|
||||
@@ -80,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||
(m) => m[1] ?? "",
|
||||
);
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
@@ -109,12 +111,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, urlParameterPairs.length]);
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
@@ -210,7 +217,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
title="Close connection"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
className="w-8 mr-0.5 !h-full"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
)
|
||||
@@ -229,7 +236,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
tabListClassName="mt-1 mb-1.5!"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
@@ -266,6 +273,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
@@ -273,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
hideLabel
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans !text-xl !px-0"
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
|
||||
@@ -105,10 +105,18 @@ function WebsocketEventRow({
|
||||
: "";
|
||||
|
||||
const iconColor =
|
||||
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
|
||||
messageType === "error"
|
||||
? "warning"
|
||||
: messageType === "close" || messageType === "open"
|
||||
? "secondary"
|
||||
: isServer
|
||||
? "info"
|
||||
: "primary";
|
||||
|
||||
const icon =
|
||||
messageType === "close" || messageType === "open"
|
||||
messageType === "error"
|
||||
? "alert_triangle"
|
||||
: messageType === "close" || messageType === "open"
|
||||
? "info"
|
||||
: isServer
|
||||
? "arrow_big_down_dash"
|
||||
@@ -119,6 +127,8 @@ function WebsocketEventRow({
|
||||
"Disconnected from server"
|
||||
) : messageType === "open" ? (
|
||||
"Connected to server"
|
||||
) : messageType === "error" ? (
|
||||
<span className="text-warning">{message}</span>
|
||||
) : message === "" ? (
|
||||
<em className="italic text-text-subtlest">No content</em>
|
||||
) : (
|
||||
@@ -170,7 +180,9 @@ function WebsocketEventDetail({
|
||||
? "Connection Closed"
|
||||
: event.messageType === "open"
|
||||
? "Connection Open"
|
||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||
: event.messageType === "error"
|
||||
? "WebSocket Error"
|
||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||
|
||||
const actions: EventDetailAction[] =
|
||||
message !== ""
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAtomValue } from "jotai";
|
||||
import * as m from "motion/react-m";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
getActiveCookieJar,
|
||||
useEnsureActiveCookieJar,
|
||||
useSubscribeActiveCookieJarId,
|
||||
} from "../hooks/useActiveCookieJar";
|
||||
@@ -33,6 +34,7 @@ import { jotaiStore } from "../lib/jotai";
|
||||
import { CreateDropdown } from "./CreateDropdown";
|
||||
import { Button } from "./core/Button";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
import { FeedbackLink } from "./core/Link";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { FolderLayout } from "./FolderLayout";
|
||||
@@ -83,7 +85,7 @@ export function Workspace() {
|
||||
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
|
||||
<div
|
||||
style={environmentBgStyle}
|
||||
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
|
||||
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
||||
@@ -160,7 +162,7 @@ function WorkspaceBody() {
|
||||
// Delay the entering because the workspaces might load after a slight delay
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Banner color="warning" className="max-w-[30rem]">
|
||||
<Banner color="warning" className="max-w-120">
|
||||
The active workspace was not found. Select a workspace from the header menu or report this
|
||||
bug to <FeedbackLink />
|
||||
</Banner>
|
||||
@@ -218,4 +220,8 @@ function useGlobalWorkspaceHooks() {
|
||||
useHotKey("model.duplicate", () =>
|
||||
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||
);
|
||||
|
||||
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
|
||||
enable: () => getActiveCookieJar() != null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
"text !px-2 truncate",
|
||||
"text px-2! truncate",
|
||||
workspace === null && "italic opacity-disabled",
|
||||
)}
|
||||
{...buttonProps}
|
||||
|
||||
@@ -20,16 +20,24 @@ import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { SettingRow } from "./core/SettingRow";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
|
||||
interface Props {
|
||||
layout?: "form" | "settings";
|
||||
size?: ButtonProps["size"];
|
||||
expanded?: boolean;
|
||||
onDone?: () => 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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
key.error != null ||
|
||||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
||||
) {
|
||||
return (
|
||||
const enterKey = (
|
||||
<EnterWorkspaceKey
|
||||
workspaceMeta={workspaceMeta}
|
||||
error={key.error}
|
||||
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return enterKey;
|
||||
}
|
||||
|
||||
// Show the key if it exists
|
||||
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
encryptionKey={key.key}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
||||
const content = (
|
||||
<VStack space={2} className="w-full">
|
||||
{justEnabledEncryption && (
|
||||
<Banner color="success" className="flex flex-col gap-2">
|
||||
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="mb-auto flex flex-col-reverse">
|
||||
<Button
|
||||
@@ -279,7 +324,7 @@ function KeyRevealer({
|
||||
|
||||
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
|
||||
return (
|
||||
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
|
||||
<span className="text-xs font-mono **:cursor-auto **:select-text">
|
||||
{show ? (
|
||||
keyText.split("").map((c, i) => {
|
||||
return (
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
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 { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { router } from "../lib/router";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { DnsOverridesEditor } from "./DnsOverridesEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
||||
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
|
||||
|
||||
@@ -25,17 +28,17 @@ interface Props {
|
||||
}
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_DATA = "data";
|
||||
const TAB_DNS = "dns";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_SETTINGS = "settings";
|
||||
|
||||
export type WorkspaceSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_DNS
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_DATA;
|
||||
| typeof TAB_SETTINGS;
|
||||
|
||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||
|
||||
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
tabs={[
|
||||
{ value: TAB_GENERAL, label: "Workspace" },
|
||||
{
|
||||
value: TAB_DATA,
|
||||
label: "Storage",
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
@@ -100,6 +103,22 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
stateKey={`headers.${workspace.id}`}
|
||||
/>
|
||||
</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">
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
|
||||
<PlainInput
|
||||
@@ -108,7 +127,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
placeholder="Workspace Name"
|
||||
label="Name"
|
||||
defaultValue={workspace.name}
|
||||
className="!text-base font-sans"
|
||||
className="text-base! font-sans"
|
||||
onChange={(name) => patchModel(workspace, { name })}
|
||||
/>
|
||||
|
||||
@@ -142,7 +161,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
||||
{workspaceId}
|
||||
<CopyIconButton
|
||||
className="opacity-70 !text-primary"
|
||||
className="opacity-70 text-primary!"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Copy workspace ID"
|
||||
@@ -152,19 +171,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
</HStack>
|
||||
</div>
|
||||
</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">
|
||||
<DnsOverridesEditor workspace={workspace} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-5rem)] max-h-200!",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AutoScroller<T>({
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
variant="border"
|
||||
className="!bg-surface z-10"
|
||||
className="bg-surface! z-10"
|
||||
onClick={() => setAutoScroll((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ export function AutoScroller<T>({
|
||||
{header ?? <span aria-hidden />}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full overflow-y-auto focus:outline-none"
|
||||
className="h-full w-full overflow-y-auto focus:outline-hidden"
|
||||
onScroll={handleScroll}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||
|
||||
describe("parseBulkPairLine", () => {
|
||||
test("parses colon-space pairs as name and value", () => {
|
||||
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves colon-without-space lines as a name with an empty value", () => {
|
||||
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo:bar",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves malformed lines instead of dropping their contents", () => {
|
||||
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "not a pair",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("unescapes newlines in parsed values", () => {
|
||||
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar\nbaz",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export function BulkPairEditor({
|
||||
const pairsText = useMemo(() => {
|
||||
return pairs
|
||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||
.map(pairToLine)
|
||||
.map(formatBulkPairLine)
|
||||
.join("\n");
|
||||
}, [pairs]);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function BulkPairEditor({
|
||||
const pairs = text
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim())
|
||||
.map(lineToPair);
|
||||
.map(parseBulkPairLine);
|
||||
onChange(pairs);
|
||||
},
|
||||
[onChange],
|
||||
@@ -47,16 +47,16 @@ export function BulkPairEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function pairToLine(pair: Pair) {
|
||||
export function formatBulkPairLine(pair: Pair) {
|
||||
const value = pair.value.replaceAll("\n", "\\n");
|
||||
return `${pair.name}: ${value}`;
|
||||
}
|
||||
|
||||
function lineToPair(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
||||
export function parseBulkPairLine(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
|
||||
return {
|
||||
enabled: true,
|
||||
name: (name ?? "").trim(),
|
||||
name: (name ?? line).trim(),
|
||||
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
||||
id: generateId(),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CheckboxProps {
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
@@ -25,6 +26,7 @@ export function Checkbox({
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
size = "sm",
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
@@ -37,8 +39,10 @@ export function Checkbox({
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"appearance-none w-4 h-4 flex-shrink-0 border border-border",
|
||||
"rounded outline-none ring-0",
|
||||
"appearance-none shrink-0 border border-border",
|
||||
size === "sm" && "w-4 h-4",
|
||||
size === "md" && "w-5 h-5",
|
||||
"rounded-sm outline-hidden ring-0",
|
||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||
disabled && "border-dotted",
|
||||
)}
|
||||
@@ -50,7 +54,7 @@ export function Checkbox({
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size="sm"
|
||||
size={size}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
|
||||
<div className={className}>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
className="w-full!"
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
regenerateKey(); // To force input to change
|
||||
@@ -96,7 +96,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
|
||||
<>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
className="w-full!"
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
regenerateKey(); // To force input to change
|
||||
|
||||
@@ -18,7 +18,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
|
||||
className={classNames(
|
||||
className,
|
||||
"flex items-center",
|
||||
"opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono",
|
||||
"opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
|
||||
color == null && "border-border-subtle",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
|
||||
@@ -42,7 +42,7 @@ export function DetailsBanner({
|
||||
return (
|
||||
<Banner color={color} className={className}>
|
||||
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
|
||||
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
|
||||
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
|
||||
<div
|
||||
className={classNames(
|
||||
"transition-transform",
|
||||
|
||||
@@ -74,13 +74,13 @@ export function Dialog({
|
||||
"relative bg-surface pointer-events-auto",
|
||||
"rounded-lg",
|
||||
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
|
||||
"min-h-[10rem]",
|
||||
"min-h-40",
|
||||
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
|
||||
size === "sm" && "w-[30rem]",
|
||||
size === "md" && "w-[50rem]",
|
||||
size === "lg" && "w-[70rem]",
|
||||
size === "full" && "w-[100vw] h-[100vh]",
|
||||
size === "dynamic" && "min-w-[20rem] max-w-[100vw]",
|
||||
size === "sm" && "w-120",
|
||||
size === "md" && "w-200",
|
||||
size === "lg" && "w-280",
|
||||
size === "full" && "w-screen h-screen",
|
||||
size === "dynamic" && "min-w-80 max-w-[100vw]",
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
|
||||
@@ -1,57 +1,111 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
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 type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type DismissibleBannerSize = "sm" | "xs";
|
||||
|
||||
export function DismissibleBanner({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
size = "sm",
|
||||
onDismiss,
|
||||
onShow,
|
||||
actions,
|
||||
...props
|
||||
}: BannerProps & {
|
||||
id: string;
|
||||
actions?: { label: string; onClick: () => void; color?: Color }[];
|
||||
size?: DismissibleBannerSize;
|
||||
onDismiss?: () => void | Promise<void>;
|
||||
onShow?: () => void | Promise<void>;
|
||||
actions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color?: Color;
|
||||
variant?: ButtonProps["variant"];
|
||||
}[];
|
||||
}) {
|
||||
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
||||
const {
|
||||
isLoading,
|
||||
set: setDismissed,
|
||||
value: dismissed,
|
||||
} = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", id],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
const shouldShow = !isLoading && !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
Promise.resolve(onShow?.()).catch(console.error);
|
||||
}
|
||||
}, [onShow, shouldShow]);
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
|
||||
const stopParentClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
|
||||
className={classNames(
|
||||
className,
|
||||
"relative",
|
||||
size === "xs" && "!px-2 !py-2 text-xs",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HStack space={1.5}>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant="border"
|
||||
color={a.color ?? props.color}
|
||||
size="xs"
|
||||
onClick={a.onClick}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size="xs"
|
||||
onClick={() => setDismissed((d) => !d)}
|
||||
title="Dismiss message"
|
||||
<div className="@container">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
||||
)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</HStack>
|
||||
{children}
|
||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
}}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-menu",
|
||||
"outline-none my-1 pointer-events-auto z-40",
|
||||
"outline-hidden my-1 pointer-events-auto z-40",
|
||||
"fixed",
|
||||
)}
|
||||
>
|
||||
@@ -734,7 +734,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded-sm font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" />
|
||||
<div className="text">{filter}</div>
|
||||
@@ -916,24 +916,24 @@ function MenuItem({
|
||||
)
|
||||
}
|
||||
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
innerClassName="!text-left"
|
||||
innerClassName="text-left!"
|
||||
color="custom"
|
||||
className={classNames(
|
||||
className,
|
||||
"h-xs", // More compact
|
||||
"min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap",
|
||||
"focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1",
|
||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded",
|
||||
item.color === "danger" && "!text-danger",
|
||||
item.color === "primary" && "!text-primary",
|
||||
item.color === "success" && "!text-success",
|
||||
item.color === "warning" && "!text-warning",
|
||||
item.color === "notice" && "!text-notice",
|
||||
item.color === "info" && "!text-info",
|
||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
||||
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||
item.color === "danger" && "text-danger!",
|
||||
item.color === "primary" && "text-primary!",
|
||||
item.color === "success" && "text-success!",
|
||||
item.color === "warning" && "text-warning!",
|
||||
item.color === "notice" && "text-notice!",
|
||||
item.color === "info" && "text-info!",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={classNames("truncate min-w-[5rem]")}>{item.label}</div>
|
||||
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@reference "../../../main.css";
|
||||
|
||||
.cm-wrapper.cm-multiline .cm-mergeView {
|
||||
@apply h-full w-full overflow-auto pr-0.5;
|
||||
|
||||
@@ -9,7 +11,7 @@
|
||||
@apply w-full min-h-full relative;
|
||||
|
||||
.cm-collapsedLines {
|
||||
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
|
||||
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +21,21 @@
|
||||
.cm-changedLine {
|
||||
/* Round top corners only if previous line is not a changed line */
|
||||
&:not(.cm-changedLine + &) {
|
||||
@apply rounded-t;
|
||||
@apply rounded-t-sm;
|
||||
}
|
||||
/* Round bottom corners only if next line is not a changed line */
|
||||
&:not(:has(+ .cm-changedLine)) {
|
||||
@apply rounded-b;
|
||||
@apply rounded-b-sm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Let content grow and disable individual scrolling for sync */
|
||||
.cm-editor {
|
||||
@apply h-auto relative !important;
|
||||
@apply h-auto! relative!;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply overflow-visible !important;
|
||||
@apply overflow-visible!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@reference "../../../main.css";
|
||||
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
@@ -7,7 +9,7 @@
|
||||
/* Regular cursor */
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-text !important;
|
||||
@apply border-text!;
|
||||
/* Widen the cursor a bit */
|
||||
@apply border-l-[2px];
|
||||
}
|
||||
@@ -15,8 +17,8 @@
|
||||
/* Vim-mode cursor */
|
||||
|
||||
.cm-fat-cursor {
|
||||
@apply outline-0 bg-text !important;
|
||||
@apply text-surface !important;
|
||||
@apply outline-0! bg-text!;
|
||||
@apply text-surface!;
|
||||
}
|
||||
|
||||
/* Matching bracket */
|
||||
@@ -59,12 +61,12 @@
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
@apply caret-transparent !important;
|
||||
@apply caret-transparent!;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
@apply bg-selection !important;
|
||||
@apply bg-selection!;
|
||||
}
|
||||
|
||||
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
|
||||
@@ -88,7 +90,7 @@
|
||||
}
|
||||
|
||||
.cm-gutter-lint {
|
||||
@apply w-auto !important;
|
||||
@apply w-auto!;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply px-0;
|
||||
@@ -111,7 +113,7 @@
|
||||
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
||||
@apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
|
||||
|
||||
-webkit-text-security: none;
|
||||
|
||||
@@ -162,7 +164,7 @@
|
||||
|
||||
&::-webkit-scrollbar-corner,
|
||||
&::-webkit-scrollbar {
|
||||
@apply hidden !important;
|
||||
@apply hidden!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,16 +191,16 @@
|
||||
|
||||
/* Style search matches */
|
||||
.cm-searchMatch {
|
||||
@apply bg-transparent !important;
|
||||
@apply bg-transparent!;
|
||||
@apply rounded-[2px] outline outline-1;
|
||||
|
||||
&.cm-searchMatch-selected {
|
||||
@apply outline-text;
|
||||
@apply bg-text !important;
|
||||
@apply bg-text!;
|
||||
|
||||
&,
|
||||
* {
|
||||
@apply text-surface font-semibold !important;
|
||||
@apply text-surface! font-semibold!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,8 +225,8 @@
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;
|
||||
@apply cursor-default !important;
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
|
||||
@apply cursor-default!;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@@ -248,7 +250,7 @@
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-border-subtle bg-surface-highlight;
|
||||
@apply hover:text-text hover:border-border-subtle text-text;
|
||||
@apply cursor-default !important;
|
||||
@apply cursor-default!;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@@ -277,7 +279,7 @@
|
||||
}
|
||||
|
||||
.cm-tooltip-lint {
|
||||
@apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important;
|
||||
@apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
|
||||
|
||||
.cm-diagnostic-error {
|
||||
@apply border-l-danger px-4 py-2;
|
||||
@@ -293,18 +295,18 @@
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
|
||||
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
|
||||
@apply p-1.5;
|
||||
|
||||
/* Style the tooltip for popping up "open in browser" and other stuff */
|
||||
|
||||
a,
|
||||
button {
|
||||
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded;
|
||||
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-default !important;
|
||||
@apply cursor-default!;
|
||||
|
||||
&::after {
|
||||
@apply text-text bg-text h-3 w-3 ml-1;
|
||||
@@ -319,10 +321,10 @@
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip-autocomplete,
|
||||
.cm-tooltip.cm-completionInfo {
|
||||
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto;
|
||||
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
|
||||
|
||||
& * {
|
||||
@apply font-mono text-editor !important;
|
||||
@apply font-mono! text-editor!;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@@ -409,7 +411,7 @@
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
|
||||
@apply text-sm flex items-center pb-0.5 shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@@ -427,7 +429,7 @@
|
||||
|
||||
input,
|
||||
button {
|
||||
@apply rounded-sm outline-none;
|
||||
@apply rounded-sm outline-hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -436,12 +438,12 @@
|
||||
}
|
||||
|
||||
button[name="close"] {
|
||||
@apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important;
|
||||
@apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply bg-surface border-border-subtle focus:border-border-focus;
|
||||
@apply border outline-none;
|
||||
@apply border outline-hidden;
|
||||
}
|
||||
|
||||
input.cm-textfield {
|
||||
|
||||
@@ -282,6 +282,22 @@ function EditorInner({
|
||||
[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(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const show = () => {
|
||||
@@ -394,9 +410,9 @@ function EditorInner({
|
||||
keymapCompartment.current.of(
|
||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||
),
|
||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
||||
...getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
stateKey,
|
||||
@@ -470,7 +486,7 @@ function EditorInner({
|
||||
const decoratedActions = useMemo(() => {
|
||||
const results = [];
|
||||
const actionClassName = classNames(
|
||||
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow",
|
||||
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
|
||||
);
|
||||
|
||||
if (format) {
|
||||
@@ -553,7 +569,6 @@ function EditorInner({
|
||||
function getExtensions({
|
||||
stateKey,
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
onChange,
|
||||
@@ -562,7 +577,7 @@ function getExtensions({
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & {
|
||||
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
|
||||
stateKey: EditorProps["stateKey"];
|
||||
container: HTMLDivElement | null;
|
||||
onChange: RefObject<EditorProps["onChange"]>;
|
||||
@@ -580,6 +595,10 @@ function getExtensions({
|
||||
|
||||
return [
|
||||
...baseExtensions, // Must be first
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
@@ -608,7 +627,6 @@ function getExtensions({
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
|
||||
...(singleLine ? [singleLineExtensions()] : []),
|
||||
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
||||
...(readOnly ? readonlyExtensions : []),
|
||||
|
||||
// ------------------------ //
|
||||
// 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}]
|
||||
}
|
||||
|
||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
||||
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const VALUE_IDENT = /\S+$/;
|
||||
const VALUE_IDENT_ONLY = /^\S+$/;
|
||||
|
||||
function normalizeFields(fields: FieldDef[]): {
|
||||
fieldNames: string[];
|
||||
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
|
||||
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 m = upto.match(IDENT);
|
||||
const m = upto.match(pattern);
|
||||
if (!m) return null;
|
||||
const from = pos - m[0].length;
|
||||
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 {
|
||||
// Lezer node names from your grammar: Phrase is the quoted token
|
||||
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
|
||||
if (inValue) {
|
||||
// word before the colon = field name
|
||||
const beforeColon = stateDoc.slice(0, lastColon);
|
||||
const m = beforeColon.match(IDENT);
|
||||
const m = beforeColon.match(FIELD_IDENT);
|
||||
fieldName = m ? m[0] : null;
|
||||
|
||||
// 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 */
|
||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
||||
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
|
||||
return fieldNames.map((name) => ({
|
||||
label: name,
|
||||
type: "property",
|
||||
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({
|
||||
changes: { from, to, insert: `${name}:` },
|
||||
selection: { anchor: from + name.length + 1 },
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
@@ -115,7 +140,7 @@ function fieldValueCompletions(
|
||||
if (!def || !def.values) return null;
|
||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||
return vals.map((v) => ({
|
||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
||||
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
|
||||
displayLabel: v,
|
||||
type: "constant",
|
||||
}));
|
||||
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const w = wordBefore(doc, pos);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
|
||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||
|
||||
// In field value position
|
||||
if (inValue && fieldName) {
|
||||
const w = wordBefore(doc, pos, VALUE_IDENT);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
const valDefs = fieldMap[fieldName];
|
||||
const vals = fieldValueCompletions(valDefs);
|
||||
|
||||
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
}
|
||||
|
||||
// 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 };
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
@skip { space+ }
|
||||
@tokens {
|
||||
space { std.whitespace+ }
|
||||
space { $[ \t\r\n]+ }
|
||||
|
||||
LParen { "(" }
|
||||
RParen { ")" }
|
||||
At { "@" }
|
||||
Colon { ":" }
|
||||
Not { "-" | "NOT" }
|
||||
|
||||
@@ -16,8 +17,10 @@
|
||||
// "quoted phrase" with simple escapes: \" and \\
|
||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||
|
||||
// field/word characters (keep generous for URLs/paths)
|
||||
Word { $[A-Za-z0-9_]+ }
|
||||
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
|
||||
// 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 }
|
||||
}
|
||||
@@ -60,12 +63,12 @@ Field {
|
||||
}
|
||||
|
||||
FieldName {
|
||||
Word
|
||||
At? Word
|
||||
}
|
||||
|
||||
FieldValue {
|
||||
Phrase
|
||||
| Term
|
||||
| FieldValueWord
|
||||
}
|
||||
|
||||
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.
|
||||
import { LRParser } from "@lezer/lr";
|
||||
import { highlight } from "./highlight";
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"%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:
|
||||
"$]~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~",
|
||||
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
|
||||
nodeNames:
|
||||
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
||||
maxTerm: 25,
|
||||
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",
|
||||
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~",
|
||||
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
|
||||
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
|
||||
maxTerm: 27,
|
||||
nodeProps: [
|
||||
["openedBy", 8, "LParen"],
|
||||
["closedBy", 9, "RParen"],
|
||||
["openedBy", 8,"LParen"],
|
||||
["closedBy", 9,"RParen"]
|
||||
],
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0, 20],
|
||||
skippedNodes: [0,22],
|
||||
repeatNodeCount: 3,
|
||||
tokenData:
|
||||
")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],
|
||||
topRules: { Query: [0, 1] },
|
||||
tokenPrec: 145,
|
||||
});
|
||||
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!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",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"Query":[0,1]},
|
||||
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"
|
||||
|
||||
// Fields
|
||||
"FieldName/At": t.attributeName,
|
||||
"FieldName/Word": t.attributeName,
|
||||
"FieldValue/Term/Word": t.attributeValue,
|
||||
"FieldValue/FieldValueWord": t.attributeValue,
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ type Tok =
|
||||
| { kind: "EOF" };
|
||||
|
||||
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[] {
|
||||
const toks: Tok[] = [];
|
||||
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
|
||||
|
||||
const readWord = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
|
||||
if (c === ":") {
|
||||
toks.push({ kind: "COLON" });
|
||||
i++;
|
||||
if (peek() && !isSpace(peek()) && peek() !== `"`) {
|
||||
toks.push({ kind: "WORD", text: readFieldValue() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === `"`) {
|
||||
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
|
||||
}
|
||||
|
||||
// WORD / AND / OR / NOT
|
||||
if (isIdent(c)) {
|
||||
if (isWordStart(c)) {
|
||||
const w = readWord();
|
||||
const upper = w.toUpperCase();
|
||||
if (upper === "AND") toks.push({ kind: "AND" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@top pairs { (Key Sep Value "\n")* }
|
||||
|
||||
@tokens {
|
||||
Sep { ":" }
|
||||
Sep { ":" $[ \t]+ }
|
||||
Key { ":"? ![:]+ }
|
||||
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],
|
||||
repeatNodeCount: 1,
|
||||
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],
|
||||
topRules: { pairs: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
|
||||
@@ -53,19 +53,17 @@ function pathParameters(
|
||||
if (node.name === "Text") {
|
||||
// Find the `url` node and then jump into it to find the placeholders
|
||||
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") {
|
||||
innerTree.toTree().iterate({
|
||||
enter(node) {
|
||||
if (node.name !== "Placeholder") return;
|
||||
const globalFrom = innerTree.node.from + node.from;
|
||||
const globalTo = innerTree.node.from + node.to;
|
||||
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
||||
const onClick = () => onClickPathParameter(rawText);
|
||||
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
||||
const deco = Decoration.replace({ widget, inclusive: false });
|
||||
widgets.push(deco.range(globalFrom, globalTo));
|
||||
},
|
||||
innerTree.node.cursor().iterate((node) => {
|
||||
if (node.name !== "Placeholder") return;
|
||||
const globalFrom = node.from;
|
||||
const globalTo = node.to;
|
||||
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
||||
const onClick = () => onClickPathParameter(rawText);
|
||||
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
||||
const deco = Decoration.replace({ widget, inclusive: false });
|
||||
widgets.push(deco.range(globalFrom, globalTo));
|
||||
});
|
||||
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)* }
|
||||
|
||||
@@ -9,9 +16,7 @@ Query { "?" queryPair ("&" queryPair)* }
|
||||
Host { $[a-zA-Z0-9-_.:\[\]]+ }
|
||||
@precedence { Protocol, Host }
|
||||
|
||||
Placeholder { ":" ![/?#]+ }
|
||||
PathSegment { ![?#/]+ }
|
||||
@precedence { Placeholder, PathSegment }
|
||||
pathChars { ![/?#:]+ }
|
||||
|
||||
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.
|
||||
export const url = 1,
|
||||
export const
|
||||
url = 1,
|
||||
Protocol = 2,
|
||||
Host = 3,
|
||||
Port = 4,
|
||||
Path = 5,
|
||||
Path = 4,
|
||||
PathSegment = 5,
|
||||
Placeholder = 6,
|
||||
PathSegment = 7,
|
||||
Query = 8;
|
||||
Query = 7
|
||||
|
||||
@@ -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.
|
||||
import { LRParser } from "@lezer/lr";
|
||||
import { highlight } from "./highlight";
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
|
||||
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~",
|
||||
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[",
|
||||
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query",
|
||||
maxTerm: 14,
|
||||
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",
|
||||
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~",
|
||||
goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
|
||||
nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
|
||||
maxTerm: 17,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
".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",
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!QZ!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",
|
||||
tokenizers: [0, 1, 2],
|
||||
topRules: { url: [0, 1] },
|
||||
tokenPrec: 63,
|
||||
});
|
||||
topRules: {"url":[0,1]},
|
||||
tokenPrec: 99
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
|
||||
import { AutoScroller } from "./AutoScroller";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { SelectProps } from "./Select";
|
||||
import { Select } from "./Select";
|
||||
import { Separator } from "./Separator";
|
||||
|
||||
interface EventViewerProps<T> {
|
||||
@@ -151,7 +153,7 @@ export function EventViewer<T>({
|
||||
layout="vertical"
|
||||
storageKey={splitLayoutStorageKey}
|
||||
defaultRatio={defaultRatio}
|
||||
minHeightPx={10}
|
||||
minHeightPx={72}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
{header ?? <span aria-hidden />}
|
||||
@@ -202,23 +204,38 @@ export function EventViewer<T>({
|
||||
);
|
||||
}
|
||||
|
||||
export interface EventDetailAction {
|
||||
/** Unique key for React */
|
||||
key: string;
|
||||
/** Button label */
|
||||
label: string;
|
||||
/** Optional icon */
|
||||
icon?: ReactNode;
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
}
|
||||
export type EventDetailAction =
|
||||
| {
|
||||
type?: "button";
|
||||
/** Unique key for React */
|
||||
key: string;
|
||||
/** Button label */
|
||||
label: string;
|
||||
/** Optional icon */
|
||||
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 {
|
||||
title: string;
|
||||
prefix?: ReactNode;
|
||||
timestamp?: string;
|
||||
actions?: EventDetailAction[];
|
||||
copyText?: string;
|
||||
copyText?: string | (() => Promise<string | null>);
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
@@ -239,34 +256,56 @@ export function EventDetailHeader({
|
||||
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
||||
</HStack>
|
||||
<HStack space={2} className="items-center">
|
||||
{actions?.map((action) => (
|
||||
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
{actions?.map((action) =>
|
||||
action.type === "select" ? (
|
||||
<div key={action.key} className="w-32">
|
||||
<Select
|
||||
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 && (
|
||||
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
||||
)}
|
||||
{formattedTime && (
|
||||
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
copyText != null ||
|
||||
formattedTime ||
|
||||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="custom"
|
||||
className="text-text-subtle -mr-3"
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Close event panel"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
{onClose != null && (
|
||||
<div
|
||||
className={classNames(
|
||||
copyText != null ||
|
||||
formattedTime ||
|
||||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="custom"
|
||||
className="text-text-subtle -mr-3"
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Close event panel"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,8 @@ export function EventViewerRow({
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left",
|
||||
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded",
|
||||
isActive && "bg-surface-active !text-text",
|
||||
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-hidden focus:text-text rounded-sm",
|
||||
isActive && "bg-surface-active text-text!",
|
||||
"text-text-subtle hover:text",
|
||||
)}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user