Compare commits

..

23 Commits

Author SHA1 Message Date
Gregory Schier 95ac3e310a Improve response history menu (#492) 2026-07-02 10:04:57 -07:00
Gregory Schier 9b524e3dc7 Upgrade Tailwind to v4 (#491) 2026-07-02 09:53:22 -07:00
Gregory Schier bdf78254b5 Disable automatic Flathub workflow 2026-07-01 13:51:00 -07:00
Gregory Schier c5545c8781 Document release note contributor attribution 2026-07-01 13:39:05 -07:00
Simon Johansson 5004c395de fix: Resolve : ambiguity in URL path placeholders (#465)
Co-authored-by: Simon Johansson <simon.johansson@infor.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 13:23:27 -07:00
Gregory Schier ea3587f28d Fix commercial use banner snooze 2026-07-01 12:44:44 -07:00
baofeidyz 24e578db5f Add SSE response summary helpers (#466)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 12:33:03 -07:00
Gregory Schier 12562aa076 Skip older PRs in contribution policy automation 2026-07-01 11:53:38 -07:00
Joris van Eijden 5a74a989b5 Support string-based url.path in Postman importer. (#490) 2026-07-01 11:47:53 -07:00
Gregory Schier a6558329e2 Run contribution policy on PR updates 2026-07-01 11:01:15 -07:00
Gregory Schier 54a931d94d Require screenshots confirmation in PR template 2026-06-30 15:24:38 -07:00
Gregory Schier 5229534d8f Clarify blocked contribution labels 2026-06-30 15:16:28 -07:00
Gregory Schier 78b3996f47 Limit community PR policy to bug fixes 2026-06-30 15:05:31 -07:00
Gregory Schier d9f7bf7fdd Remove PR body note from policy comments 2026-06-30 15:00:54 -07:00
Gregory Schier 45c410dd4c Clarify contribution policy blocker wording 2026-06-30 14:57:29 -07:00
Gregory Schier 80e56281b2 Include PR template in policy comment 2026-06-30 14:45:44 -07:00
Gregory Schier 125eae052b Shorten contribution policy inputs 2026-06-30 14:40:15 -07:00
Gregory Schier 6f52bb7533 Clarify explicit contribution permission 2026-06-30 14:36:46 -07:00
Gregory Schier 8724260eb4 Allow targeted contribution policy runs 2026-06-30 14:25:41 -07:00
Gregory Schier f32e9f7704 Refine contribution policy labels 2026-06-30 14:20:13 -07:00
Gregory Schier 83c8371e94 Support contribution policy label overrides 2026-06-30 14:15:37 -07:00
Gregory Schier 5f14d90ccd Preview contribution policy comments 2026-06-30 13:54:45 -07:00
Gregory Schier ff0d8c03b0 Improve contribution policy summary 2026-06-30 13:41:02 -07:00
149 changed files with 2484 additions and 1360 deletions
@@ -19,10 +19,12 @@ Generate formatted markdown release notes for a Yaak tag.
- `gh pr view <PR_NUMBER> --json number,title,body,author,url` - `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details: 5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`) - Feedback URLs (`feedback.yaak.app`)
- Contributor GitHub handles from `author.login`
- Plugin install links or other notable context - Plugin install links or other notable context
6. Format notes using Yaak style: 6. Format notes using Yaak style:
- Changelog badge at top - Changelog badge at top
- Bulleted items with PR links where available - Bulleted items with PR links where available
- Contributor handles for external PRs
- Feedback links where available - Feedback links where available
- Full changelog compare link at bottom - Full changelog compare link at bottom
@@ -31,6 +33,7 @@ Generate formatted markdown release notes for a Yaak tag.
- Wrap final notes in a markdown code fence. - Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence. - Keep a blank line before and after the code fence.
- Output the markdown code block last. - Output the markdown code block last.
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
- Do not append `by @gschier` for PRs authored by `@gschier`. - Do not append `by @gschier` for PRs authored by `@gschier`.
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. - These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
+4 -3
View File
@@ -4,13 +4,14 @@
## Submission ## Submission
- [ ] This PR is a bug fix or small-scope improvement. - [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below. - [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement): Explicit permission feedback item (required if not a bug fix):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
+366 -84
View File
@@ -1,51 +1,69 @@
const fs = require("node:fs");
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->"; const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]); const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]); const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20; const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800; const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const LABELS = { const LABELS = {
accepted: { inScope: {
name: "contribution: accepted", name: "contribution: in scope",
color: "0E8A16", color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.", description: "Community PR appears to be in scope for maintainer review.",
}, },
approvedFeedback: { outOfScope: {
name: "contribution: approved feedback", name: "contribution: out of scope",
color: "5319E7",
description: "Community PR links an approved feedback item.",
},
needsTemplate: {
name: "contribution: needs template",
color: "D93F0B",
description: "Community PR needs a completed pull request template.",
},
needsApproval: {
name: "contribution: needs approval",
color: "B60205", color: "B60205",
description: "Community PR needs an approved feedback item before review.", description: "Community PR does not match Yaak's contribution policy.",
}, },
largeDiff: { explicitPermission: {
name: "contribution: large diff", 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", color: "FBCA04",
description: description:
"Community PR has a larger-than-usual diff for a small-scope contribution.", "Community PR may be broader than Yaak's bug-fix contribution policy.",
}, },
}; };
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name); const MANAGED_LABEL_NAMES = [
...new Set(Object.values(LABELS).map((label) => label.name)),
];
const CHECKBOXES = { const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.", bugFix: "This PR is a bug fix.",
approvedFeedback: explicitPermission:
"If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.", "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
readContributing: readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).", "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.", testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.", testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
"I added screenshots or recordings for UI changes when reasonable.",
}; };
function escapeRegExp(value) { function escapeRegExp(value) {
@@ -77,20 +95,31 @@ function hasMeaningfulText(value) {
return stripComments(value || "").length > 0; return stripComments(value || "").length > 0;
} }
function checkboxState(body, label) { function normalizeCheckboxLabel(label) {
const flexibleLabel = escapeRegExp(label).replace(/\\ /g, "\\s+"); return label
const pattern = new RegExp( .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
`^\\s*[-*]\\s*\\[([ xX])\\]\\s*${flexibleLabel}\\s*$`, .replace(/`/g, "")
"im", .replace(/\s+/g, " ")
); .trim();
const match = body.match(pattern);
if (match == null) {
return null;
} }
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 match[1].toLowerCase() === "x";
} }
}
return null;
}
function findFeedbackUrl(body) { function findFeedbackUrl(body) {
return ( return (
@@ -100,8 +129,13 @@ function findFeedbackUrl(body) {
); );
} }
function getLabelNames(pr) {
return new Set((pr.labels || []).map((label) => label.name));
}
function analyzePullRequest(pr) { function analyzePullRequest(pr) {
const body = normalizeBody(pr.body); const body = normalizeBody(pr.body);
const labelNames = getLabelNames(pr);
const states = Object.fromEntries( const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [ Object.entries(CHECKBOXES).map(([key, label]) => [
key, key,
@@ -123,9 +157,38 @@ function analyzePullRequest(pr) {
changedFiles > LARGE_DIFF_CHANGED_FILES || changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES; 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) { if (!templateUsed) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.missingTemplate.name,
message: message:
"Update the PR description with the repository pull request template.", "Update the PR description with the repository pull request template.",
}); });
@@ -133,70 +196,83 @@ function analyzePullRequest(pr) {
const summary = getSection(body, "Summary"); const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary); const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body); const feedbackUrl = findFeedbackUrl(body);
const smallScope = states.smallScope === true; const bugFix = states.bugFix === true;
const approvedFeedback = states.approvedFeedback === true; const explicitPermission = states.explicitPermission === true;
if (!hasSummary) { if (!hasSummary) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Add a short summary describing the bug fix or improvement.", message:
"Add a short summary describing the bug fix or permitted change.",
}); });
} }
if (smallScope && approvedFeedback) { if (bugFix && explicitPermission) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: message:
"Choose either the small-scope checkbox or the approved-feedback checkbox, not both.", "Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
}); });
} else if (!smallScope && !approvedFeedback) { } else if (!bugFix && !explicitPermission) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: message:
"Check whether this is a bug fix or small-scope improvement, or confirm that an approved feedback item is linked.", "Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
}); });
} else if (approvedFeedback && feedbackUrl == null) { } else if (explicitPermission && feedbackUrl == null) {
blockers.push({ blockers.push({
label: LABELS.needsApproval.name, label: LABELS.policyUnmet.name,
message: message:
"Link the approved feedback item where contribution approval was explicitly stated.", "Link the feedback item where @gschier explicitly gave you permission to work on this.",
}); });
} }
if (states.readContributing !== true) { if (states.readContributing !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.", message: "Confirm that `CONTRIBUTING.md` was read and followed.",
}); });
} }
if (states.testedLocally !== true) { if (states.testedLocally !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that the change was tested locally.", message: "Confirm that the change was tested locally.",
}); });
} }
if (states.testsUpdated !== true) { if (states.testsUpdated !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.", 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(blockers.map((blocker) => blocker.label)); const desiredLabels = new Set();
if (blockers.length === 0) { if (blockers.length === 0) {
desiredLabels.add( desiredLabels.add(
states.approvedFeedback largeDiff
? LABELS.approvedFeedback.name ? LABELS.needsScopeReview.name
: LABELS.accepted.name, : states.explicitPermission
? LABELS.explicitPermission.name
: LABELS.inScope.name,
); );
} } else if (
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
if (largeDiff) { ) {
desiredLabels.add(LABELS.largeDiff.name); desiredLabels.add(LABELS.missingTemplate.name);
} else {
desiredLabels.add(LABELS.policyUnmet.name);
} }
return { return {
@@ -204,6 +280,7 @@ function analyzePullRequest(pr) {
changedFiles, changedFiles,
desiredLabels: [...desiredLabels], desiredLabels: [...desiredLabels],
largeDiff, largeDiff,
status: blockers.length === 0 ? "in_scope" : "blocked",
templateUsed, templateUsed,
totalChangedLines, totalChangedLines,
}; };
@@ -212,43 +289,147 @@ function analyzePullRequest(pr) {
function buildBlockingComment(analysis) { function buildBlockingComment(analysis) {
const lines = [ const lines = [
COMMENT_MARKER, COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes and small-scope improvements, plus larger changes that link an approved feedback item from https://yaak.app/feedback.", "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. Please update the PR description to address:", "This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
"", "",
...analysis.blockers.map((blocker) => `- ${blocker.message}`), ...analysis.blockers.map((blocker) => `- ${blocker.message}`),
]; ];
if (analysis.largeDiff) { if (!analysis.templateUsed) {
lines.push( lines.push(
"", "",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as a large diff. That label is advisory, but maintainers may ask for the scope to be reduced.`, "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( lines.push(
"", "",
"I did not overwrite the PR body, since that can remove useful context. Editing the description directly is the safest way to keep your notes while completing the template.", `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"); return lines.join("\n");
} }
function summarizeResult({ pr, analysis, skipped, skipReason }) { function getPullRequestTemplate() {
if (skipped) { return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
return `#${pr.number} ${pr.title} - skipped (${skipReason})`;
} }
const status = function buildInScopeComment() {
analysis.blockers.length > 0 return [
? `blocked: ${analysis.blockers.map((blocker) => blocker.message).join("; ")}` COMMENT_MARKER,
: "accepted"; "Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
const labels = "",
analysis.desiredLabels.length > 0 "This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
? analysis.desiredLabels.join(", ") ].join("\n");
: "none"; }
return `#${pr.number} ${pr.title} - ${status}; labels: ${labels}`; function buildOutOfScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
"",
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
"",
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
].join("\n");
}
function buildPolicyComment(analysis) {
if (analysis.status === "out_of_scope") {
return buildOutOfScopeComment();
}
if (analysis.blockers.length > 0) {
return buildBlockingComment(analysis);
}
return buildInScopeComment();
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function truncateTitle(title) {
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
}
function escapeTableText(value) {
return escapeHtml(value).replace(/\n/g, "<br>");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
const comment =
analysis == null
? "None"
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
const summary = {
blocked: analysis?.blockers.length > 0,
comment,
details: "None",
labels:
analysis?.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "None",
number: pr.number,
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
status: "In scope",
title: escapeHtml(truncateTitle(pr.title)),
};
if (skipped) {
return {
...summary,
blocked: false,
comment: "None",
details: escapeHtml(skipReason),
labels: "None",
status: "Skipped",
};
}
if (summary.blocked) {
return {
...summary,
comment: escapeTableText(summary.comment),
details: escapeHtml(
analysis.blockers.map((blocker) => blocker.message).join("; "),
),
labels: escapeHtml(summary.labels),
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
};
}
return {
...summary,
comment: escapeTableText(summary.comment),
labels: escapeHtml(summary.labels),
};
}
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
} }
async function isOfficialMaintainer({ github, owner, repo, pr }) { async function isOfficialMaintainer({ github, owner, repo, pr }) {
@@ -394,6 +575,27 @@ async function deletePolicyComment({ github, owner, repo, issueNumber }) {
}); });
} }
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({ async function checkPullRequest({
github, github,
core, core,
@@ -401,6 +603,7 @@ async function checkPullRequest({
repo, repo,
pullNumber, pullNumber,
dryRun, dryRun,
skipCreatedBefore,
}) { }) {
const response = await github.rest.pulls.get({ const response = await github.rest.pulls.get({
owner, owner,
@@ -410,6 +613,25 @@ async function checkPullRequest({
const pr = response.data; const pr = response.data;
const issueNumber = pr.number; 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) { if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`); core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return { return {
@@ -448,7 +670,9 @@ async function checkPullRequest({
if (dryRun) { if (dryRun) {
const summary = summarizeResult({ pr, analysis }); const summary = summarizeResult({ pr, analysis });
core.notice(`[dry-run] ${summary}`); core.notice(
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
);
return { return {
blocked: analysis.blockers.length > 0, blocked: analysis.blockers.length > 0,
number: pr.number, number: pr.number,
@@ -471,7 +695,7 @@ async function checkPullRequest({
owner, owner,
repo, repo,
issueNumber, issueNumber,
body: buildBlockingComment(analysis), body: buildPolicyComment(analysis),
}); });
return { return {
blocked: true, blocked: true,
@@ -481,7 +705,14 @@ async function checkPullRequest({
}; };
} }
await deletePolicyComment({ github, owner, repo, issueNumber }); 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}.`); core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return { return {
blocked: false, blocked: false,
@@ -500,20 +731,59 @@ async function listOpenPullRequests({ github, owner, repo }) {
}); });
} }
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 }) { async function run({ github, context, core }) {
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request; const payloadPr = context.payload.pull_request;
const dryRunInput = context.payload.inputs?.dry_run;
const dryRun = const dryRun =
context.eventName === "workflow_dispatch" && context.eventName === "workflow_dispatch" &&
context.payload.inputs?.dry_run !== "false"; 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 = const pullRequests =
payloadPr == null pullNumbers == null
? await listOpenPullRequests({ github, owner, repo }) ? await listOpenPullRequests({ github, owner, repo })
: [payloadPr]; : pullNumbers.map((number) => ({ number }));
const results = []; const results = [];
if (dryRun) { if (dryRun) {
core.notice("Running contribution policy in dry-run mode."); 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) { for (const pr of pullRequests) {
@@ -525,6 +795,7 @@ async function run({ github, context, core }) {
repo, repo,
pullNumber: pr.number, pullNumber: pr.number,
dryRun, dryRun,
skipCreatedBefore,
}), }),
); );
} }
@@ -534,9 +805,20 @@ async function run({ github, context, core }) {
.addTable([ .addTable([
[ [
{ data: "PR", header: true }, { data: "PR", header: true },
{ data: "Result", 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.number}`, result.summary]), ...results.map((result) => [
result.summary.prLink,
result.summary.title,
result.summary.status,
result.summary.labels,
result.summary.details,
result.summary.comment,
]),
]) ])
.write(); .write();
+17 -2
View File
@@ -3,16 +3,30 @@ name: Contribution Policy
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
pr:
description: PR number or all
required: true
default: all
type: string
dry_run: dry_run:
description: Preview labels and comments without changing PRs description: Dry run
required: true required: true
default: true default: true
type: boolean type: boolean
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
- labeled
- unlabeled
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: read pull-requests: write
jobs: jobs:
check: check:
@@ -22,6 +36,7 @@ jobs:
- name: Checkout policy script - name: Checkout policy script
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.base.sha || github.ref }}
fetch-depth: 1 fetch-depth: 1
- name: Check contribution policy - name: Check contribution policy
+9 -6
View File
@@ -1,7 +1,12 @@
name: Update Flathub name: Update Flathub
on: on:
release: workflow_dispatch:
types: [published] inputs:
tag:
description: Release tag to publish to Flathub
required: true
type: string
permissions: permissions:
contents: read contents: read
@@ -10,8 +15,6 @@ jobs:
update-flathub: update-flathub:
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps: steps:
- name: Checkout app repo - name: Checkout app repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,7 +42,7 @@ jobs:
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh - name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo
- name: Commit and push to Flathub - name: Commit and push to Flathub
working-directory: flathub-repo working-directory: flathub-repo
@@ -48,5 +51,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0 git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}" git commit -m "Update to ${{ inputs.tag }}"
git push git push
+1 -2
View File
@@ -3,13 +3,12 @@
Yaak accepts community pull requests for: Yaak accepts community pull requests for:
- Bug fixes - Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes ## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated. If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
## Development Setup ## Development Setup
+2 -2
View File
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements. > Community PRs are currently limited to bug fixes.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback). > If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources ## Useful Resources
@@ -10,7 +10,7 @@ export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
id: "folder-settings", id: "folder-settings",
title: null, title: null,
size: "lg", size: "lg",
className: "h-[50rem]", className: "h-200",
noPadding: true, noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />, render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
}); });
@@ -38,7 +38,7 @@ export function BinaryFileEditor({
<VStack space={2}> <VStack space={2}>
<SelectFile onChange={handleChange} filePath={filePath} /> <SelectFile onChange={handleChange} filePath={filePath} />
{filePath != null && mimeType !== contentType && !ignoreContentType.value && ( {filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5"> <Banner className="mt-3 py-5!">
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div>Set Content-Type header</div> <div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request? <InlineCode>{mimeType}</InlineCode> for current request?
@@ -108,7 +108,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
rightSlot={ rightSlot={
<IconButton <IconButton
size="xs" size="xs"
className="mr-0.5 !h-auto my-0.5" className="mr-0.5 h-auto! my-0.5"
icon="folder" icon="folder"
title="Browse" title="Browse"
onClick={handleSelectDirectory} onClick={handleSelectDirectory}
@@ -11,7 +11,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined }; const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames( const finalClassName = classNames(
className, className,
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0", "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
); );
if (onClick) { if (onClick) {
@@ -439,7 +439,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
name="command" name="command"
label="Command" label="Command"
placeholder="Search or type a command" placeholder="Search or type a command"
className="font-sans !text-base" className="font-sans text-base!"
defaultValue={command} defaultValue={command}
onChange={handleSetCommand} onChange={handleSetCommand}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
@@ -448,7 +448,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1"> <div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
{filteredGroups.map((g) => ( {filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full"> <div key={g.key} className="mb-1.5 w-full">
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center"> <Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center">
{g.label} {g.label}
</Heading> </Heading>
{g.items.map((v) => ( {g.items.map((v) => (
@@ -491,7 +491,7 @@ function CommandPaletteItem({
color="custom" color="custom"
justify="start" justify="start"
className={classNames( className={classNames(
"w-full h-sm flex items-center rounded px-1.5", "w-full h-sm flex items-center rounded-sm px-1.5",
"hover:text-text", "hover:text-text",
active && "bg-surface-highlight", active && "bg-surface-highlight",
!active && "text-text-subtle", !active && "text-text-subtle",
@@ -54,7 +54,7 @@ export function CommercialUseBanner({
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error); setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]); }, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || snoozed) { if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
return null; return null;
} }
@@ -96,10 +96,10 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
try { try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check"); const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status !== "active" && license.status !== "trialing"; return license.status === "personal_use";
} catch (err) { } catch (err) {
console.log("Failed to check license before commercial-use prompt", err); console.log("Failed to check license before commercial-use prompt", err);
return true; return false;
} }
} }
+5 -5
View File
@@ -155,7 +155,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
rightSlot={ rightSlot={
filter.length > 0 && ( filter.length > 0 && (
<IconButton <IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1" className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x" icon="x"
title="Clear filter" title="Clear filter"
onClick={() => { onClick={() => {
@@ -239,7 +239,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}> <TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name} {c.name}
</TableCell> </TableCell>
<TruncatedWideTableCell className="min-w-[10rem]"> <TruncatedWideTableCell className="min-w-40">
{c.value} {c.value}
</TruncatedWideTableCell> </TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell> <TableCell>{cookieDomain(c)}</TableCell>
@@ -547,7 +547,7 @@ function CookieEditor({
} }
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) { function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />; return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
} }
function CookieTextInput({ function CookieTextInput({
@@ -589,7 +589,7 @@ function CookieTextarea({ onChange, value }: { onChange: (value: string) => void
<textarea <textarea
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")} className={classNames(cookieInputClassName, "min-h-20 resize-y")}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
value={value} value={value}
/> />
@@ -600,7 +600,7 @@ const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*"; const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames( const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent", "x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-none", "border border-border-subtle outline-hidden",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder", "px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger", "focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted", "disabled:opacity-disabled disabled:border-dotted",
@@ -75,7 +75,7 @@ export function DnsOverridesEditor({ workspace }: Props) {
<VStack space={3} className="pb-3"> <VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm"> <div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{" "} Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but <code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
only for requests made from this workspace. only for requests made from this workspace.
</div> </div>
+2 -2
View File
@@ -23,8 +23,8 @@ export const DropMarker = memo(
<div <div
className={classNames( className={classNames(
"absolute bg-primary rounded-full", "absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]", orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]", orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
)} )}
/> />
</div> </div>
+5 -5
View File
@@ -204,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
summary={input.label} summary={input.label}
className={classNames("!mb-auto", disabled && "opacity-disabled")} className={classNames("mb-auto!", disabled && "opacity-disabled")}
> >
<div className="mt-3"> <div className="mt-3">
<FormInputsStack <FormInputsStack
@@ -300,7 +300,7 @@ function TextArg({
onChange, onChange,
name: arg.name, name: arg.name,
multiLine: arg.multiLine, multiLine: arg.multiLine,
className: arg.multiLine ? "min-h-[4rem]" : undefined, className: arg.multiLine ? "min-h-16" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value, defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional, required: !arg.optional,
disabled: arg.disabled, disabled: arg.disabled,
@@ -359,7 +359,7 @@ function EditorArg({
className={classNames( className={classNames(
"border border-border rounded-md overflow-hidden px-2 py-1", "border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus", "focus-within:border-border-focus",
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space !arg.rows && "max-h-40", // So it doesn't take up too much space
)} )}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined} style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
> >
@@ -372,7 +372,7 @@ function EditorArg({
onChange={onChange} onChange={onChange}
hideGutter hideGutter
heightMode="auto" heightMode="auto"
className="min-h-[3rem]" className="min-h-12"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value} defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined} placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
@@ -392,7 +392,7 @@ function EditorArg({
id: "id", id: "id",
size: "full", size: "full",
title: arg.readOnly ? "View Value" : "Edit Value", title: arg.readOnly ? "View Value" : "Edit Value",
className: "!max-w-[50rem] !max-h-[60rem]", className: "max-w-200! max-h-240!",
description: arg.label && ( description: arg.label && (
<Label <Label
htmlFor={id} htmlFor={id}
@@ -62,7 +62,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text !px-2 truncate", "text px-2! truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic", !activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)} )}
// If no environments, the button simply opens the dialog. // If no environments, the button simply opens the dialog.
@@ -57,7 +57,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
defaultRatio={0.75} defaultRatio={0.75}
layout="horizontal" layout="horizontal"
className="gap-0" className="gap-0"
resizeHandleClassName="-translate-x-[1px]" resizeHandleClassName="-translate-x-px"
firstSlot={() => ( firstSlot={() => (
<EnvironmentEditDialogSidebar <EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null} selectedEnvironmentId={selectedEnvironment?.id ?? null}
+2 -2
View File
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
return ( return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden"> <div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0"> <code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url} {request.method} {request.url}
</code> </code>
{latestResponse ? ( {latestResponse ? (
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
className={classNames( className={classNames(
"cursor-default select-none", "cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full", "font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
)} )}
> >
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />} {latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
@@ -84,12 +84,12 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl"> <div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" /> <Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1"> <div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => ( {breadcrumbs.map((item, index) => (
<Fragment key={item.id}> <Fragment key={item.id}>
{index > 0 && ( {index > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)} )}
<span className="text-text-subtle truncate min-w-0" title={item.name}> <span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name} {item.name}
@@ -97,7 +97,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
</Fragment> </Fragment>
))} ))}
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)} )}
<span className="whitespace-nowrap" title={folder.name}> <span className="whitespace-nowrap" title={folder.name}>
{folder.name} {folder.name}
@@ -149,7 +149,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id} {folder.id}
<CopyIconButton <CopyIconButton
className="opacity-70 !text-primary" className="opacity-70 text-primary!"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy folder ID" title="Copy folder ID"
+1 -1
View File
@@ -126,7 +126,7 @@ export function GrpcEditor({
const actions = useMemo( const actions = useMemo(
() => [ () => [
<div key="reflection" className={classNames(services == null && "!opacity-100")}> <div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button <Button
size="xs" size="xs"
color={ color={
@@ -162,7 +162,7 @@ export function GrpcRequestPane({
className={classNames( className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5", "grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0", paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1", paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
)} )}
> >
<UrlBar <UrlBar
@@ -201,7 +201,7 @@ export function GrpcRequestPane({
rightSlot={<Icon size="sm" icon="chevron_down" />} rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null} disabled={isStreaming || services == null}
className={classNames( className={classNames(
"font-mono text-editor min-w-[5rem] !ring-0", "font-mono text-editor min-w-20 ring-0!",
paneWidth < 400 && "flex-1", paneWidth < 400 && "flex-1",
)} )}
> >
@@ -259,7 +259,7 @@ export function GrpcRequestPane({
<Tabs <Tabs
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 mb-1.5!"
storageKey="grpc_request_tabs" storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id} activeTabKey={activeRequest.id}
> >
@@ -296,7 +296,7 @@ export function GrpcRequestPane({
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
@@ -156,7 +156,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
title="Authentication Actions" title="Authentication Actions"
icon="settings" icon="settings"
size="xs" size="xs"
className="!text-secondary" className="text-secondary!"
/> />
</Dropdown> </Dropdown>
)} )}
@@ -57,7 +57,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
<GraphQLDocsExplorer <GraphQLDocsExplorer
requestId={activeRequest.id} requestId={activeRequest.id}
schema={graphQLSchema} schema={graphQLSchema}
className={classNames(orientation === "horizontal" && "!ml-0")} className={classNames(orientation === "horizontal" && "ml-0!")}
style={style} style={style}
/> />
)} )}
@@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -131,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -347,7 +346,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onUrlChange={handleUrlChange} onUrlChange={handleUrlChange}
leftSlot={ leftSlot={
<div className="py-0.5"> <div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" /> <RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div> </div>
} }
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
@@ -457,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
hideLabel hideLabel
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
@@ -4,10 +4,12 @@ import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react"; import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react"; import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType"; import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
@@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.state === "closed" && redirectDropWarning != null; activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
? [] ? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
], ],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
}, },
}, },
{ {
@@ -135,12 +155,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
], ],
[ [
activeResponse?.headers, activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength, activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length, activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent, cookieCounts.sent,
cookieCounts.received, cookieCounts.received,
copyResponse.mutate,
mimeType, mimeType,
responseEvents.data?.length, responseEvents.data?.length,
saveResponse.mutate,
setViewMode, setViewMode,
viewMode, viewMode,
timelineViewMode, timelineViewMode,
@@ -167,7 +193,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack
className={classNames( className={classNames(
"text-text-subtle w-full flex-shrink-0", "text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too // Remove a bit of space because the tabs have lots too
"-mb-1.5", "-mb-1.5",
)} )}
@@ -180,7 +206,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)} )}
> >
<HStack space={2} className="w-full flex-shrink-0"> <HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />} {activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} /> <HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span> <span>&bull;</span>
@@ -194,7 +220,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
{shouldShowRedirectDropWarning ? ( {shouldShowRedirectDropWarning ? (
<Tooltip <Tooltip
tabIndex={0} tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden" className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={ content={
<VStack alignItems="start" space={1} className="text-xs"> <VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning"> <span className="font-medium text-warning">
@@ -223,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<span className="inline-flex min-w-0"> <span className="inline-flex min-w-0">
<PillButton <PillButton
color="warning" color="warning"
className="font-sans text-sm !flex-shrink max-w-full" className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center" innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />} leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
> >
@@ -236,7 +262,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : ( ) : (
<span /> <span />
)} )}
<div className="justify-self-end flex-shrink-0"> <div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown <RecentHttpResponsesDropdown
responses={responses} responses={responses}
activeResponse={activeResponse} activeResponse={activeResponse}
@@ -249,7 +275,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="overflow-hidden flex flex-col min-h-0"> <div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && ( {activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0"> <Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error} {activeResponse.error}
</Banner> </Banner>
)} )}
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const actions = useMemo<EditorProps["actions"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
showBanner && ( showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs"> <Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0"> <p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span> <span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" /> <Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p> </p>
</Banner> </Banner>
), ),
<div key="settings" className="!opacity-100 !shadow"> <div key="settings" className="opacity-100! shadow!">
<Dropdown <Dropdown
onOpen={handleDropdownOpen} onOpen={handleDropdownOpen}
items={ items={
+1 -1
View File
@@ -59,7 +59,7 @@ function getDetail(
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`, label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
}, },
{ {
label: <div className="min-w-[12rem]">Renew License</div>, label: <div className="min-w-48">Renew License</div>,
leftSlot: <Icon icon="refresh" />, leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />, rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null, hidden: data.data.changesUrl == null,
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor <Editor
hideGutter hideGutter
wrapLines wrapLines
className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")} className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")}
language="markdown" language="markdown"
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onChange} onChange={onChange}
@@ -46,7 +46,7 @@ export function MarkdownEditor({
defaultValue.length === 0 ? ( defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p> <p className="text-text-subtlest">No description</p>
) : ( ) : (
<div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto"> <div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto">
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown> <Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
</div> </div>
); );
@@ -539,7 +539,7 @@ function NumberUnitInput({
placeholder={placeholder} placeholder={placeholder}
defaultValue={value} defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48" containerClassName="w-48!"
validate={validate} validate={validate}
rightSlot={ rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle"> <span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
@@ -66,7 +66,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
<Button <Button
size="xs" size="xs"
color="secondary" color="secondary"
className="mr-auto min-w-[5rem]" className="mr-auto min-w-20"
onClick={async () => { onClick={async () => {
await router.navigate({ await router.navigate({
to: "/workspaces/$workspaceId", to: "/workspaces/$workspaceId",
+7 -5
View File
@@ -1,3 +1,5 @@
@reference "../main.css";
.prose { .prose {
@apply text-text; @apply text-text;
@@ -98,7 +100,7 @@
@apply text-notice hover:underline; @apply text-notice hover:underline;
* { * {
@apply text-notice !important; @apply text-notice!;
} }
} }
@@ -113,12 +115,12 @@
ol code, ol code,
ul code { ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap; @apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded not-italic; @apply px-1.5 py-0.5 rounded-sm not-italic;
@apply select-text; @apply select-text;
} }
pre { pre {
@apply bg-surface-highlight text-text !important; @apply bg-surface-highlight! text-text!;
@apply px-4 py-3 rounded-md; @apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre; @apply overflow-auto whitespace-pre;
@apply text-editor font-mono; @apply text-editor font-mono;
@@ -130,7 +132,7 @@
.banner { .banner {
@apply border border-dashed; @apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base; @apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base;
&::before { &::before {
@apply block font-bold mb-1; @apply block font-bold mb-1;
@@ -161,7 +163,7 @@
} }
blockquote { blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg; @apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg;
p { p {
@apply m-0; @apply m-0;
@@ -1,10 +1,17 @@
import type { GrpcConnection } from "@yaakapp-internal/models"; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({
}: Props) { }: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -36,16 +100,7 @@ export function RecentGrpcConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -1,13 +1,21 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useSaveResponse } from "../hooks/useSaveResponse"; import { useKeyValue } from "../hooks/useKeyValue";
import { pluralize } from "../lib/pluralize"; import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -22,32 +30,93 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a"; const latestResponseId = responses[0]?.id ?? "n/a";
const saveResponse = useSaveResponse(activeResponse); const responseHistoryItems: DropdownItem[] = [];
const copyResponse = useCopyHttpResponse(activeResponse); let lastHistoryGroup: string | null = null;
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return ( return (
<Dropdown <Dropdown
items={[ items={[
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{ {
label: "Delete", label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse), onSelect: () => deleteModel(activeResponse),
}, },
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{ {
label: "Unpin Response", label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id), onSelect: () => onPinnedResponseId(activeResponse.id),
@@ -55,25 +124,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: "separator", label: "History" },
{ {
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`, type: "content",
onSelect: deleteAllResponses.mutate, hidden: dismissedMovedActions === true,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{ type: "separator" },
...responses.map((r: HttpResponse) => ({
label: ( label: (
<HStack space={2}> <DismissibleBanner
<HttpStatusTag short className="text-xs" response={r} /> id={movedActionsBannerId}
<span className="text-text-subtle">&rarr;</span>{" "} color="info"
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span> size="xs"
</HStack> className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, },
onSelect: () => onPinnedResponseId(r.id), {
})), type: "separator",
label: "Recent",
},
...responseHistoryItems,
]} ]}
> >
<IconButton <IconButton
@@ -1,10 +1,17 @@
import type { WebsocketConnection } from "@yaakapp-internal/models"; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -19,6 +26,63 @@ export function RecentWebsocketConnectionsDropdown({
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -40,16 +104,7 @@ export function RecentWebsocketConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -167,7 +167,7 @@ export function ResponseCookies({ response }: Props) {
{cookie.value} {cookie.value}
</span> </span>
{cookie.isDeleted && ( {cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded"> <span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
Deleted Deleted
</span> </span>
)} )}
@@ -1,5 +1,6 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react"; import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -29,12 +30,20 @@ export function ResponseHeaders({ response }: Props) {
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5"> <div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}> <DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows> <KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL"> <KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span> <span className="select-text cursor-text">{response.url}</span>
<IconButton <IconButton
iconSize="sm" iconSize="sm"
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100" className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link" icon="external_link"
onClick={() => openUrl(response.url)} onClick={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -24,7 +24,7 @@ export function ResponseInfo({ response }: Props) {
URL URL
<IconButton <IconButton
iconSize="sm" iconSize="sm"
className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100" className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link" icon="external_link"
onClick={() => openUrl(response.url)} onClick={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -10,7 +10,7 @@ export default function RouteError({ error }: { error: unknown }) {
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null; typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<VStack space={5} className="w-[50rem] !h-auto"> <VStack space={5} className="w-200 h-auto!">
<Heading>Route Error 🔥</Heading> <Heading>Route Error 🔥</Heading>
<FormattedError> <FormattedError>
{message} {message}
+1 -1
View File
@@ -108,7 +108,7 @@ export function SelectFile({
"rtl mr-1.5", "rtl mr-1.5",
inline && "w-full", inline && "w-full",
filePath && inline && "font-mono text-xs", filePath && inline && "font-mono text-xs",
isHovering && "!border-notice", isHovering && "border-notice!",
)} )}
color={isHovering ? "primary" : "secondary"} color={isHovering ? "primary" : "secondary"}
onClick={handleClick} onClick={handleClick}
@@ -93,7 +93,7 @@ export default function Settings({ hide }: Props) {
layout="horizontal" layout="horizontal"
defaultValue={mainTab || tabFromQuery} defaultValue={mainTab || tabFromQuery}
addBorders addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3" tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings" label="Settings"
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
@@ -131,28 +131,28 @@ export default function Settings({ hide }: Props) {
}), }),
)} )}
> >
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral /> <SettingsGeneral />
</TabContent> </TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface /> <SettingsInterface />
</TabContent> </TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme /> <SettingsTheme />
</TabContent> </TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy /> <SettingsProxy />
</TabContent> </TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates /> <SettingsCertificates />
</TabContent> </TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense /> <SettingsLicense />
</TabContent> </TabContent>
</Tabs> </Tabs>
@@ -56,7 +56,7 @@ export function SettingsGeneral() {
model={settings} model={settings}
modelKey="updateChannel" modelKey="updateChannel"
label="Update Channel" label="Update Channel"
selectClassName="!w-full" selectClassName="w-full!"
options={[ options={[
{ label: "Stable", value: "stable" }, { label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" }, { label: "Beta", value: "beta" },
@@ -341,7 +341,7 @@ function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps
}} }}
className={classNames( className={classNames(
"flex items-center justify-center", "flex items-center justify-center",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full", "px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
"border-border-subtle focus:border-border-focus", "border-border-subtle focus:border-border-focus",
)} )}
> >
@@ -89,7 +89,7 @@ export function SettingsInterface() {
<SettingSelectControl <SettingSelectControl
name="uiFont" name="uiFont"
label="Interface font" label="Interface font"
selectClassName="!w-72" selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE} value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE} defaultValue={NULL_FONT_VALUE}
options={[ options={[
@@ -106,7 +106,7 @@ export function SettingsInterface() {
<SettingSelectControl <SettingSelectControl
name="interfaceFontSize" name="interfaceFontSize"
label="Interface Font Size" label="Interface Font Size"
selectClassName="!w-20" selectClassName="w-20!"
value={`${settings.interfaceFontSize}`} value={`${settings.interfaceFontSize}`}
defaultValue="14" defaultValue="14"
options={fontSizeOptions} options={fontSizeOptions}
@@ -123,7 +123,7 @@ export function SettingsInterface() {
<SettingSelectControl <SettingSelectControl
name="editorFont" name="editorFont"
label="Editor font" label="Editor font"
selectClassName="!w-72" selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE} value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE} defaultValue={NULL_FONT_VALUE}
options={[ options={[
@@ -139,7 +139,7 @@ export function SettingsInterface() {
<SettingSelectControl <SettingSelectControl
name="editorFontSize" name="editorFontSize"
label="Editor Font Size" label="Editor Font Size"
selectClassName="!w-20" selectClassName="w-20!"
value={`${settings.editorFontSize}`} value={`${settings.editorFontSize}`}
defaultValue="12" defaultValue="12"
options={fontSizeOptions} options={fontSizeOptions}
@@ -211,7 +211,7 @@ function PluginTableRow({
return ( return (
<TableRow> <TableRow>
{showCheckbox && ( {showCheckbox && (
<TableCell className="!py-0"> <TableCell className="py-0!">
<Checkbox <Checkbox
hideLabel hideLabel
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"} title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
@@ -249,7 +249,7 @@ function PluginTableRow({
)} )}
</HStack> </HStack>
</TableCell> </TableCell>
<TableCell className="!py-0"> <TableCell className="py-0!">
<HStack justifyContent="end" space={1.5}> <HStack justifyContent="end" space={1.5}>
{plugin != null && latestVersion != null ? ( {plugin != null && latestVersion != null ? (
<Button <Button
@@ -56,7 +56,7 @@ export function SettingsProxy() {
{ label: "Custom proxy configuration", value: "enabled" }, { label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" }, { label: "No proxy", value: "disabled" },
]} ]}
selectClassName="!w-64" selectClassName="w-64!"
/> />
</SettingsSection> </SettingsSection>
@@ -99,7 +99,7 @@ export function SettingsProxy() {
description="Comma-separated list of hosts that should bypass the proxy." description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass} value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000" placeholder="127.0.0.1, *.example.com, localhost:3000"
inputWidthClassName="!w-96" inputWidthClassName="w-96!"
onChange={(bypass) => patchProxy({ bypass })} onChange={(bypass) => patchProxy({ bypass })}
/> />
</SettingsSection> </SettingsSection>
@@ -120,7 +120,7 @@ export function SettingsTheme() {
<SettingsSection title="Preview"> <SettingsSection title="Preview">
<VStack <VStack
space={3} space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto" 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}> <HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} /> <Icon icon={appearance === "dark" ? "moon" : "sun"} />
+6 -6
View File
@@ -588,7 +588,7 @@ function Sidebar({ className }: { className?: string }) {
rightSlot={ rightSlot={
filterText.text && ( filterText.text && (
<IconButton <IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1" className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x" icon="x"
title="Clear filter" title="Clear filter"
onClick={clearFilterText} onClick={clearFilterText}
@@ -667,8 +667,8 @@ function Sidebar({ className }: { className?: string }) {
<div className="p-3 text-sm text-center"> <div className="p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? ( {(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText <EmptyStateText
wrapperClassName="!h-auto mb-auto" wrapperClassName="h-auto! mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center" className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
> >
<div> <div>
No results, but found matches for{" "} No results, but found matches for{" "}
@@ -677,7 +677,7 @@ function Sidebar({ className }: { className?: string }) {
{i > 0 && " or "} {i > 0 && " or "}
<button <button
type="button" type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info" className="max-w-full rounded-sm align-middle focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)} 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"> <InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
@@ -690,8 +690,8 @@ function Sidebar({ className }: { className?: string }) {
</EmptyStateText> </EmptyStateText>
) : ( ) : (
<EmptyStateText <EmptyStateText
wrapperClassName="!h-auto mb-auto" wrapperClassName="h-auto! mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center" className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
> >
<div> <div>
No results for{" "} No results for{" "}
@@ -208,10 +208,10 @@ function InitializedTemplateFunctionDialog({
)} )}
/> />
</HStack> </HStack>
<div className="relative w-full max-h-[10rem]"> <div className="relative w-full max-h-40">
<InlineCode <InlineCode
className={classNames( className={classNames(
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest", "block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger", tooLarge && "italic text-danger",
)} )}
> >
@@ -246,7 +246,7 @@ function InitializedTemplateFunctionDialog({
) : ( ) : (
<span /> <span />
)} )}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1"> <div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && ( {templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}> <Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key Reveal Encryption Key
@@ -271,7 +271,7 @@ TemplateFunctionDialog.show = (
showDialog({ showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md", size: "md",
className: "h-[60rem]", className: "h-240",
noPadding: true, noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>, title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description, description: fn.description,
+1 -1
View File
@@ -94,7 +94,7 @@ export const UrlBar = memo(function UrlBar({
iconSize="md" iconSize="md"
title="Send Request" title="Send Request"
type="submit" type="submit"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 h-full!"
iconColor="secondary" iconColor="secondary"
icon={isLoading ? "x" : submitIcon} icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send" hotkeyAction="request.send"
@@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
@@ -83,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -218,7 +217,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
title="Close connection" title="Close connection"
icon="x" icon="x"
iconColor="secondary" iconColor="secondary"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 h-full!"
onClick={handleCancel} onClick={handleCancel}
/> />
) )
@@ -237,7 +236,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
ref={tabsRef} ref={tabsRef}
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 mb-1.5!"
storageKey={TABS_STORAGE_KEY} storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId} activeTabKey={activeRequestId}
> >
@@ -284,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
+2 -2
View File
@@ -85,7 +85,7 @@ export function Workspace() {
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" /> <div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div <div
style={environmentBgStyle} style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20" className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/> />
</div> </div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} /> <WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
@@ -162,7 +162,7 @@ function WorkspaceBody() {
// Delay the entering because the workspaces might load after a slight delay // Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
<Banner color="warning" className="max-w-[30rem]"> <Banner color="warning" className="max-w-120">
The active workspace was not found. Select a workspace from the header menu or report this The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink /> bug to <FeedbackLink />
</Banner> </Banner>
@@ -176,7 +176,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text !px-2 truncate", "text px-2! truncate",
workspace === null && "italic opacity-disabled", workspace === null && "italic opacity-disabled",
)} )}
{...buttonProps} {...buttonProps}
@@ -324,7 +324,7 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) { function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return ( return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text"> <span className="text-xs font-mono **:cursor-auto **:select-text">
{show ? ( {show ? (
keyText.split("").map((c, i) => { keyText.split("").map((c, i) => {
return ( return (
@@ -127,7 +127,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
placeholder="Workspace Name" placeholder="Workspace Name"
label="Name" label="Name"
defaultValue={workspace.name} defaultValue={workspace.name}
className="!text-base font-sans" className="text-base! font-sans"
onChange={(name) => patchModel(workspace, { name })} onChange={(name) => patchModel(workspace, { name })}
/> />
@@ -161,7 +161,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{workspaceId} {workspaceId}
<CopyIconButton <CopyIconButton
className="opacity-70 !text-primary" className="opacity-70 text-primary!"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy workspace ID" title="Copy workspace ID"
@@ -182,7 +182,7 @@ WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab)
showDialog({ showDialog({
id: "workspace-settings", id: "workspace-settings",
size: "lg", size: "lg",
className: "h-[calc(100vh-5rem)] !max-h-[50rem]", className: "h-[calc(100vh-5rem)] max-h-200!",
noPadding: true, noPadding: true,
render: ({ hide }) => ( render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} /> <WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
@@ -72,7 +72,7 @@ export function AutoScroller<T>({
size="sm" size="sm"
iconSize="md" iconSize="md"
variant="border" variant="border"
className="!bg-surface z-10" className="bg-surface! z-10"
onClick={() => setAutoScroll((v) => !v)} onClick={() => setAutoScroll((v) => !v)}
/> />
</div> </div>
@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full overflow-y-auto focus:outline-none" className="h-full w-full overflow-y-auto focus:outline-hidden"
onScroll={handleScroll} onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined} tabIndex={focusable ? 0 : undefined}
> >
@@ -39,10 +39,10 @@ export function Checkbox({
<input <input
aria-hidden aria-hidden
className={classNames( className={classNames(
"appearance-none flex-shrink-0 border border-border", "appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4", size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5", size === "md" && "w-5 h-5",
"rounded outline-none ring-0", "rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]", !disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted", disabled && "border-dotted",
)} )}
@@ -17,7 +17,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
<div className={className}> <div className={className}>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="!w-full" className="w-full!"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -96,7 +96,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
<> <>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="!w-full" className="w-full!"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -18,7 +18,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
className={classNames( className={classNames(
className, className,
"flex items-center", "flex items-center",
"opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono", "opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
color == null && "border-border-subtle", color == null && "border-border-subtle",
color === "primary" && "text-primary", color === "primary" && "text-primary",
color === "secondary" && "text-secondary", color === "secondary" && "text-secondary",
@@ -42,7 +42,7 @@ export function DetailsBanner({
return ( return (
<Banner color={color} className={className}> <Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}> <details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70"> <summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div <div
className={classNames( className={classNames(
"transition-transform", "transition-transform",
+6 -6
View File
@@ -74,13 +74,13 @@ export function Dialog({
"relative bg-surface pointer-events-auto", "relative bg-surface pointer-events-auto",
"rounded-lg", "rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]", "border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-[10rem]", "min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]", "max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-[30rem]", size === "sm" && "w-120",
size === "md" && "w-[50rem]", size === "md" && "w-200",
size === "lg" && "w-[70rem]", size === "lg" && "w-280",
size === "full" && "w-[100vw] h-[100vh]", size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-[20rem] max-w-[100vw]", size === "dynamic" && "min-w-80 max-w-[100vw]",
)} )}
> >
{title ? ( {title ? (
@@ -2,21 +2,26 @@ import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { MouseEvent } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button"; import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
type DismissibleBannerSize = "sm" | "xs";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
size = "sm",
onDismiss, onDismiss,
onShow, onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
size?: DismissibleBannerSize;
onDismiss?: () => void | Promise<void>; onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>; onShow?: () => void | Promise<void>;
actions?: { actions?: {
@@ -46,17 +51,36 @@ export function DismissibleBanner({
if (!shouldShow) return null; if (!shouldShow) return null;
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
const stopParentClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<Banner className={classNames(className, "relative")} {...props}> <Banner
className={classNames(
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
{...props}
>
<div className="@container"> <div className="@container">
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3"> <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",
)}
>
{children} {children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end"> <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button <Button
variant="border" variant="border"
color={props.color} color={props.color}
size="xs" size={actionSize}
onClick={() => { onClick={(event) => {
stopParentClick(event);
setDismissed(true).catch(console.error); setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error); Promise.resolve(onDismiss?.()).catch(console.error);
}} }}
@@ -69,8 +93,11 @@ export function DismissibleBanner({
key={a.label} key={a.label}
variant={a.variant ?? "border"} variant={a.variant ?? "border"}
color={a.color ?? props.color} color={a.color ?? props.color}
size="xs" size={actionSize}
onClick={a.onClick} onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label} title={a.label}
> >
{a.label} {a.label}
+13 -13
View File
@@ -712,7 +712,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
className={classNames( className={classNames(
className, className,
"x-theme-menu", "x-theme-menu",
"outline-none my-1 pointer-events-auto z-40", "outline-hidden my-1 pointer-events-auto z-40",
"fixed", "fixed",
)} )}
> >
@@ -734,7 +734,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
{filter && ( {filter && (
<HStack <HStack
space={2} space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs" className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded-sm font-mono h-xs"
> >
<Icon icon="search" size="xs" /> <Icon icon="search" size="xs" />
<div className="text">{filter}</div> <div className="text">{filter}</div>
@@ -916,24 +916,24 @@ function MenuItem({
) )
} }
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left" innerClassName="text-left!"
color="custom" color="custom"
className={classNames( className={classNames(
className, className,
"h-xs", // More compact "h-xs", // More compact
"min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap", "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1", "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded", isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
item.color === "danger" && "!text-danger", item.color === "danger" && "text-danger!",
item.color === "primary" && "!text-primary", item.color === "primary" && "text-primary!",
item.color === "success" && "!text-success", item.color === "success" && "text-success!",
item.color === "warning" && "!text-warning", item.color === "warning" && "text-warning!",
item.color === "notice" && "!text-notice", item.color === "notice" && "text-notice!",
item.color === "info" && "!text-info", item.color === "info" && "text-info!",
)} )}
{...props} {...props}
> >
<div className={classNames("truncate min-w-[5rem]")}>{item.label}</div> <div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button> </Button>
); );
} }
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper.cm-multiline .cm-mergeView { .cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5; @apply h-full w-full overflow-auto pr-0.5;
@@ -9,7 +11,7 @@
@apply w-full min-h-full relative; @apply w-full min-h-full relative;
.cm-collapsedLines { .cm-collapsedLines {
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default; @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
} }
} }
@@ -19,21 +21,21 @@
.cm-changedLine { .cm-changedLine {
/* Round top corners only if previous line is not a changed line */ /* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) { &:not(.cm-changedLine + &) {
@apply rounded-t; @apply rounded-t-sm;
} }
/* Round bottom corners only if next line is not a changed line */ /* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) { &:not(:has(+ .cm-changedLine)) {
@apply rounded-b; @apply rounded-b-sm;
} }
} }
/* Let content grow and disable individual scrolling for sync */ /* Let content grow and disable individual scrolling for sync */
.cm-editor { .cm-editor {
@apply h-auto relative !important; @apply h-auto! relative!;
position: relative !important; position: relative !important;
} }
.cm-scroller { .cm-scroller {
@apply overflow-visible !important; @apply overflow-visible!;
} }
} }
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper { .cm-wrapper {
@apply h-full overflow-hidden; @apply h-full overflow-hidden;
@@ -7,7 +9,7 @@
/* Regular cursor */ /* Regular cursor */
.cm-cursor { .cm-cursor {
@apply border-text !important; @apply border-text!;
/* Widen the cursor a bit */ /* Widen the cursor a bit */
@apply border-l-[2px]; @apply border-l-[2px];
} }
@@ -15,8 +17,8 @@
/* Vim-mode cursor */ /* Vim-mode cursor */
.cm-fat-cursor { .cm-fat-cursor {
@apply outline-0 bg-text !important; @apply outline-0! bg-text!;
@apply text-surface !important; @apply text-surface!;
} }
/* Matching bracket */ /* Matching bracket */
@@ -59,12 +61,12 @@
* { * {
@apply cursor-text; @apply cursor-text;
@apply caret-transparent !important; @apply caret-transparent!;
} }
} }
.cm-selectionBackground { .cm-selectionBackground {
@apply bg-selection !important; @apply bg-selection!;
} }
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost /* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
@@ -88,7 +90,7 @@
} }
.cm-gutter-lint { .cm-gutter-lint {
@apply w-auto !important; @apply w-auto!;
.cm-gutterElement { .cm-gutterElement {
@apply px-0; @apply px-0;
@@ -111,7 +113,7 @@
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default; @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border hover:text-text hover:bg-surface-highlight; @apply hover:border-border hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow; @apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
-webkit-text-security: none; -webkit-text-security: none;
@@ -162,7 +164,7 @@
&::-webkit-scrollbar-corner, &::-webkit-scrollbar-corner,
&::-webkit-scrollbar { &::-webkit-scrollbar {
@apply hidden !important; @apply hidden!;
} }
} }
} }
@@ -189,16 +191,16 @@
/* Style search matches */ /* Style search matches */
.cm-searchMatch { .cm-searchMatch {
@apply bg-transparent !important; @apply bg-transparent!;
@apply rounded-[2px] outline outline-1; @apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected { &.cm-searchMatch-selected {
@apply outline-text; @apply outline-text;
@apply bg-text !important; @apply bg-text!;
&, &,
* { * {
@apply text-surface font-semibold !important; @apply text-surface! font-semibold!;
} }
} }
} }
@@ -223,8 +225,8 @@
} }
.cm-editor .fold-gutter-icon { .cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded; @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
@apply cursor-default !important; @apply cursor-default!;
} }
.cm-editor .fold-gutter-icon::after { .cm-editor .fold-gutter-icon::after {
@@ -248,7 +250,7 @@
.cm-editor .cm-foldPlaceholder { .cm-editor .cm-foldPlaceholder {
@apply px-2 border border-border-subtle bg-surface-highlight; @apply px-2 border border-border-subtle bg-surface-highlight;
@apply hover:text-text hover:border-border-subtle text-text; @apply hover:text-text hover:border-border-subtle text-text;
@apply cursor-default !important; @apply cursor-default!;
} }
.cm-editor .cm-activeLineGutter { .cm-editor .cm-activeLineGutter {
@@ -277,7 +279,7 @@
} }
.cm-tooltip-lint { .cm-tooltip-lint {
@apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important; @apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
.cm-diagnostic-error { .cm-diagnostic-error {
@apply border-l-danger px-4 py-2; @apply border-l-danger px-4 py-2;
@@ -293,18 +295,18 @@
} }
.cm-tooltip.cm-tooltip-hover { .cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm; @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
@apply p-1.5; @apply p-1.5;
/* Style the tooltip for popping up "open in browser" and other stuff */ /* Style the tooltip for popping up "open in browser" and other stuff */
a, a,
button { button {
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded; @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
} }
a { a {
@apply cursor-default !important; @apply cursor-default!;
&::after { &::after {
@apply text-text bg-text h-3 w-3 ml-1; @apply text-text bg-text h-3 w-3 ml-1;
@@ -319,10 +321,10 @@
/* NOTE: Extra selector required to override default styles */ /* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete, .cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo { .cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto; @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
& * { & * {
@apply font-mono text-editor !important; @apply font-mono! text-editor!;
} }
.cm-completionIcon { .cm-completionIcon {
@@ -409,7 +411,7 @@
} }
.cm-completionIcon { .cm-completionIcon {
@apply text-sm flex items-center pb-0.5 flex-shrink-0; @apply text-sm flex items-center pb-0.5 shrink-0;
} }
.cm-completionLabel { .cm-completionLabel {
@@ -427,7 +429,7 @@
input, input,
button { button {
@apply rounded-sm outline-none; @apply rounded-sm outline-hidden;
} }
button { button {
@@ -436,12 +438,12 @@
} }
button[name="close"] { button[name="close"] {
@apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important; @apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
} }
input { input {
@apply bg-surface border-border-subtle focus:border-border-focus; @apply bg-surface border-border-subtle focus:border-border-focus;
@apply border outline-none; @apply border outline-hidden;
} }
input.cm-textfield { input.cm-textfield {
@@ -282,6 +282,22 @@ function EditorInner({
[disableTabIndent], [disableTabIndent],
); );
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
@@ -394,9 +410,9 @@ function EditorInner({
keymapCompartment.current.of( keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
), ),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({ ...getExtensions({
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
stateKey, stateKey,
@@ -470,7 +486,7 @@ function EditorInner({
const decoratedActions = useMemo(() => { const decoratedActions = useMemo(() => {
const results = []; const results = [];
const actionClassName = classNames( const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow", "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
); );
if (format) { if (format) {
@@ -553,7 +569,6 @@ function EditorInner({
function getExtensions({ function getExtensions({
stateKey, stateKey,
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
onChange, onChange,
@@ -562,7 +577,7 @@ function getExtensions({
onFocus, onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & { }: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"]; stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>; onChange: RefObject<EditorProps["onChange"]>;
@@ -612,7 +627,6 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []), ...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ // // ------------------------ //
// Things that must be last // // Things that must be last //
@@ -53,19 +53,17 @@ function pathParameters(
if (node.name === "Text") { if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders // Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) { for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i); const innerTree = tree.resolveInner(i);
if (innerTree.node.name === "url") { if (innerTree.node.name === "url") {
innerTree.toTree().iterate({ innerTree.node.cursor().iterate((node) => {
enter(node) {
if (node.name !== "Placeholder") return; if (node.name !== "Placeholder") return;
const globalFrom = innerTree.node.from + node.from; const globalFrom = node.from;
const globalTo = innerTree.node.from + node.to; const globalTo = node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo); const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const onClick = () => onClickPathParameter(rawText); const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const deco = Decoration.replace({ widget, inclusive: false }); const deco = Decoration.replace({ widget, inclusive: false });
widgets.push(deco.range(globalFrom, globalTo)); widgets.push(deco.range(globalFrom, globalTo));
},
}); });
break; break;
} }
@@ -1,6 +1,13 @@
@top url { Protocol? Host Path? Query? } // Host is optional so URLs starting with `/` go straight to Path. Without this,
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }
Path { ("/" (Placeholder | PathSegment))+ } Path { ("/" PathSegment)+ }
Placeholder { ":" pathChars }
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
Query { "?" queryPair ("&" queryPair)* } Query { "?" queryPair ("&" queryPair)* }
@@ -9,9 +16,7 @@ Query { "?" queryPair ("&" queryPair)* }
Host { $[a-zA-Z0-9-_.:\[\]]+ } Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host } @precedence { Protocol, Host }
Placeholder { ":" ![/?#]+ } pathChars { ![/?#:]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
} }
@@ -1,9 +1,9 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
export const url = 1, export const
url = 1,
Protocol = 2, Protocol = 2,
Host = 3, Host = 3,
Port = 4, Path = 4,
Path = 5, PathSegment = 5,
Placeholder = 6, Placeholder = 6,
PathSegment = 7, Query = 7
Query = 8;
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";
function expectValidParse(input: string) {
expect(parser.parse(input).toString()).not.toContain("⚠");
}
function placeholderValues(input: string): string[] {
const values: string[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
});
return values;
}
describe("URL grammar Placeholder", () => {
test("recognizes path placeholders", () => {
expectValidParse("https://x.com/users/:id");
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
});
test("treats a colon suffix as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
});
test("treats repeated colon suffixes as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
});
test("does not recognize a colon in the middle of a plain path segment", () => {
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
});
test("does not recognize query parameters as path placeholders", () => {
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
});
test("recognizes placeholders in a path fragment after a templated base URL", () => {
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
// as in `${[ URL ]}/x/:foo/:hello`.
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
":foo",
":hello",
]);
});
});
@@ -1,20 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from "@lezer/lr"; import {LRParser} from "@lezer/lr"
import { highlight } from "./highlight"; import {highlight} from "./highlight"
export const parser = LRParser.deserialize({ export const parser = LRParser.deserialize({
version: 14, version: 14,
states: states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c", stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~", goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[", nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 17,
maxTerm: 14,
propSources: [highlight], propSources: [highlight],
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 2, repeatNodeCount: 3,
tokenData: tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
tokenizers: [0, 1, 2], tokenizers: [0, 1, 2],
topRules: { url: [0, 1] }, topRules: {"url":[0,1]},
tokenPrec: 63, tokenPrec: 99
}); })
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller"; import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button"; import { Button } from "./Button";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
interface EventViewerProps<T> { interface EventViewerProps<T> {
@@ -151,7 +153,7 @@ export function EventViewer<T>({
layout="vertical" layout="vertical"
storageKey={splitLayoutStorageKey} storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio} defaultRatio={defaultRatio}
minHeightPx={10} minHeightPx={72}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
@@ -202,7 +204,9 @@ export function EventViewer<T>({
); );
} }
export interface EventDetailAction { export type EventDetailAction =
| {
type?: "button";
/** Unique key for React */ /** Unique key for React */
key: string; key: string;
/** Button label */ /** Button label */
@@ -212,13 +216,26 @@ export interface EventDetailAction {
/** Click handler */ /** Click handler */
onClick: () => void; onClick: () => void;
} }
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps { interface EventDetailHeaderProps {
title: string; title: string;
prefix?: ReactNode; prefix?: ReactNode;
timestamp?: string; timestamp?: string;
actions?: EventDetailAction[]; actions?: EventDetailAction[];
copyText?: string; copyText?: string | (() => Promise<string | null>);
onClose?: () => void; onClose?: () => void;
} }
@@ -239,7 +256,20 @@ export function EventDetailHeader({
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3> <h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack> </HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) =>
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 <Button
key={action.key} key={action.key}
type="button" type="button"
@@ -250,13 +280,15 @@ export function EventDetailHeader({
{action.icon} {action.icon}
{action.label} {action.label}
</Button> </Button>
))} ),
)}
{copyText != null && ( {copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" /> <CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)} )}
{formattedTime && ( {formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span> <span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)} )}
{onClose != null && (
<div <div
className={classNames( className={classNames(
copyText != null || copyText != null ||
@@ -273,6 +305,7 @@ export function EventDetailHeader({
onClick={onClose} onClick={onClose}
/> />
</div> </div>
)}
</HStack> </HStack>
</div> </div>
); );
@@ -24,8 +24,8 @@ export function EventViewerRow({
onClick={onClick} onClick={onClick}
className={classNames( className={classNames(
"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left", "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", "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", isActive && "bg-surface-active text-text!",
"text-text-subtle hover:text", "text-text-subtle hover:text",
)} )}
> >
+1 -1
View File
@@ -30,7 +30,7 @@ export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
className={classNames( className={classNames(
className, className,
variant === "with-bg" && variant === "with-bg" &&
"rounded bg-surface-highlight px-1 border border-border text-text-subtle", "rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest", variant === "text" && "text-text-subtlest",
)} )}
> >
@@ -81,7 +81,7 @@ export function HttpMethodTagRaw({
colored && m === "PATCH" && "text-notice", colored && m === "PATCH" && "text-notice",
colored && m === "POST" && "text-success", colored && m === "POST" && "text-success",
colored && m === "DELETE" && "text-danger", colored && m === "DELETE" && "text-danger",
"font-mono flex-shrink-0 whitespace-pre", "font-mono shrink-0 whitespace-pre",
"pt-[0.15em]", // Fix for monospace font not vertically centering "pt-[0.15em]", // Fix for monospace font not vertically centering
)} )}
> >
@@ -31,7 +31,7 @@ export function HttpResponseDurationTag({ response }: Props) {
); );
} }
function formatMillis(ms: number) { export function formatMillis(ms: number) {
if (ms < 1000) { if (ms < 1000) {
return `${ms} ms`; return `${ms} ms`;
} }
+5 -5
View File
@@ -201,7 +201,7 @@ function BaseInput({
const id = useRef(`input-${generateId()}`); const id = useRef(`input-${generateId()}`);
const editorClassName = classNames( const editorClassName = classNames(
className, className,
"!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder", "bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder",
); );
const isValid = useMemo(() => { const isValid = useMemo(() => {
@@ -264,7 +264,7 @@ function BaseInput({
"border", "border",
focused && !disabled ? "border-border-focus" : "border-border", focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted", disabled && "border-dotted",
!isValid && hasChanged && "!border-danger", !isValid && hasChanged && "border-danger!",
size === "md" && "min-h-md", size === "md" && "min-h-md",
size === "sm" && "min-h-sm", size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs", size === "xs" && "min-h-xs",
@@ -333,7 +333,7 @@ function BaseInput({
: `Obscure ${typeof label === "string" ? label : "field"}` : `Obscure ${typeof label === "string" ? label : "field"}`
} }
size="xs" size="xs"
className={classNames("mr-0.5 !h-auto my-0.5", disabled && "opacity-disabled")} className={classNames("mr-0.5 h-auto! my-0.5", disabled && "opacity-disabled")}
color={tint} color={tint}
// iconClassName={classNames( // iconClassName={classNames(
// tint === 'primary' && 'text-primary', // tint === 'primary' && 'text-primary',
@@ -548,9 +548,9 @@ function EncryptionInput({
color={tint} color={tint}
aria-label="Configure encryption" aria-label="Configure encryption"
className={classNames( className={classNames(
"flex items-center justify-center !h-full !px-1", "flex items-center justify-center h-full! px-1!",
"opacity-70", // Makes it a bit subtler "opacity-70", // Makes it a bit subtler
props.disabled && "!opacity-disabled", props.disabled && "opacity-disabled!",
)} )}
> >
<HStack space={0.5}> <HStack space={0.5}>
@@ -73,7 +73,7 @@ export function KeyValueRow({
<> <>
<td <td
className={classNames( className={classNames(
"select-none py-0.5 pr-2 h-full max-w-[10rem]", "select-none py-0.5 pr-2 h-full max-w-40",
align === "top" && "align-top", align === "top" && "align-top",
align === "middle" && "align-middle", align === "middle" && "align-middle",
labelClassName, labelClassName,
@@ -86,12 +86,12 @@ export function KeyValueRow({
</td> </td>
<td <td
className={classNames( className={classNames(
"select-none py-0.5 break-all max-w-[15rem]", "select-none py-0.5 break-all max-w-60",
align === "top" && "align-top", align === "top" && "align-top",
align === "middle" && "align-middle", align === "middle" && "align-middle",
)} )}
> >
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]"> <div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />} {leftSlot ?? <span aria-hidden />}
{children} {children}
{resolvedRightSlot ? ( {resolvedRightSlot ? (
+1 -1
View File
@@ -27,7 +27,7 @@ export function Label({
className={classNames( className={classNames(
className, className,
visuallyHidden && "sr-only", visuallyHidden && "sr-only",
"flex-shrink-0 text-sm", "shrink-0 text-sm",
"text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5", "text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5",
)} )}
{...props} {...props}
@@ -566,7 +566,7 @@ export function PairEditorRow({
title={pair.enabled ? "Disable item" : "Enable item"} title={pair.enabled ? "Disable item" : "Enable item"}
disabled={isLast || disabled} disabled={isLast || disabled}
checked={isLast ? false : !!pair.enabled} checked={isLast ? false : !!pair.enabled}
className={classNames(isLast && "!opacity-disabled")} className={classNames(isLast && "opacity-disabled!")}
onChange={handleChangeEnabled} onChange={handleChangeEnabled}
/> />
{!isLast && !disableDrag ? ( {!isLast && !disableDrag ? (
@@ -586,7 +586,7 @@ export function PairEditorRow({
<div <div
className={classNames( className={classNames(
"grid items-center", "grid items-center",
"@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]", "@xs:gap-2 @xs:grid-rows-1! @xs:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]!",
"gap-0.5 grid-cols-1 grid-rows-2", "gap-0.5 grid-cols-1 grid-rows-2",
)} )}
> >
@@ -830,7 +830,7 @@ function MultilineEditDialog({
const [value, setValue] = useState<string>(defaultValue); const [value, setValue] = useState<string>(defaultValue);
const language = languageFromContentType(contentType, value); const language = languageFromContentType(contentType, value);
return ( return (
<div className="w-[100vw] max-w-[40rem] h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]"> <div className="w-screen max-w-160 h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
<Editor <Editor
heightMode="auto" heightMode="auto"
defaultValue={defaultValue} defaultValue={defaultValue}
@@ -26,7 +26,7 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
variant="border" variant="border"
title={useBulk ? "Enable form edit" : "Enable bulk edit"} title={useBulk ? "Enable form edit" : "Enable bulk edit"}
className={classNames( className={classNames(
"transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow", "transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
"bg-surface hover:text group-hover/wrapper:opacity-100", "bg-surface hover:text group-hover/wrapper:opacity-100",
)} )}
onClick={() => setUseBulk((b) => !b)} onClick={() => setUseBulk((b) => !b)}
@@ -7,7 +7,7 @@ export function PillButton({ className, ...props }: ButtonProps) {
<Button <Button
size="2xs" size="2xs"
variant="border" variant="border"
className={classNames(className, "!rounded-full mx-1 !px-3")} className={classNames(className, "rounded-full! mx-1 px-3!")}
{...props} {...props}
/> />
); );
@@ -116,7 +116,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
const id = useRef(`input-${generateId()}`); const id = useRef(`input-${generateId()}`);
const commonClassName = classNames( const commonClassName = classNames(
className, className,
"!bg-transparent min-w-0 w-full focus:outline-none placeholder:text-placeholder", "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
"px-2 text-xs font-mono cursor-text", "px-2 text-xs font-mono cursor-text",
); );
@@ -167,7 +167,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
"overflow-hidden", "overflow-hidden",
focused && !disabled ? "border-border-focus" : "border-border-subtle", focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted", disabled && "border-dotted",
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation hasChanged && "has-invalid:border-danger", // For built-in HTML validation
size === "md" && "min-h-md", size === "md" && "min-h-md",
size === "sm" && "min-h-sm", size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs", size === "xs" && "min-h-xs",
@@ -225,7 +225,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
: `Obscure ${typeof label === "string" ? label : "field"}` : `Obscure ${typeof label === "string" ? label : "field"}`
} }
size="xs" size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5" className="mr-0.5 group/obscure h-auto! my-0.5"
iconClassName="group-hover/obscure:text" iconClassName="group-hover/obscure:text"
iconSize="sm" iconSize="sm"
icon={obscured ? "eye" : "eye_closed"} icon={obscured ? "eye" : "eye_closed"}
@@ -43,7 +43,7 @@ export function RadioCards<T extends string>({
/> />
<div <div
className={classNames( className={classNames(
"mt-1 w-4 h-4 flex-shrink-0 rounded-full border", "mt-1 w-4 h-4 shrink-0 rounded-full border",
"flex items-center justify-center", "flex items-center justify-center",
selected ? "border-focus" : "border-border", selected ? "border-focus" : "border-border",
)} )}
@@ -92,7 +92,7 @@ export function SegmentedControl<T extends string>({
role="radio" role="radio"
tabIndex={isSelected ? 0 : -1} tabIndex={isSelected ? 0 : -1}
className={classNames( className={classNames(
isActive && "!text-text", isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus", "focus:ring-1 focus:ring-border-focus",
)} )}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
@@ -111,8 +111,8 @@ export function SegmentedControl<T extends string>({
role="radio" role="radio"
tabIndex={isSelected ? 0 : -1} tabIndex={isSelected ? 0 : -1}
className={classNames( className={classNames(
isActive && "!text-text", isActive && "text-text!",
"!px-1.5 !w-auto", "px-1.5! w-auto!",
"focus:ring-border-focus", "focus:ring-border-focus",
)} )}
title={o.label} title={o.label}
+2 -2
View File
@@ -90,8 +90,8 @@ export function Select<T extends string>({
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
disabled={disabled} disabled={disabled}
className={classNames( className={classNames(
"pr-7 w-full outline-none bg-transparent disabled:opacity-disabled", "pr-7 w-full outline-hidden bg-transparent disabled:opacity-disabled",
"leading-[1] rounded-none", // Center the text better vertically "leading-none rounded-none", // Center the text better vertically
)} )}
> >
{isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>} {isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>}
@@ -189,7 +189,7 @@ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfV
export function SettingRowNumber({ export function SettingRowNumber({
inputClassName, inputClassName,
inputWidthClassName = "!w-48", inputWidthClassName = "w-48!",
name, name,
onChange, onChange,
placeholder, placeholder,
@@ -251,7 +251,7 @@ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfVa
export function SettingRowText({ export function SettingRowText({
inputClassName, inputClassName,
inputWidthClassName = "!w-80", inputWidthClassName = "w-80!",
name, name,
onChange, onChange,
placeholder, placeholder,
@@ -358,7 +358,7 @@ export function SettingRowSelect<T extends string>({
name, name,
onChange, onChange,
options, options,
selectClassName = "!w-48", selectClassName = "w-48!",
title, title,
value, value,
...props ...props
@@ -393,7 +393,7 @@ export function SettingSelectControl<T extends string>({
name, name,
onChange, onChange,
options, options,
selectClassName = "!w-48", selectClassName = "w-48!",
value, value,
}: { }: {
defaultValue?: T; defaultValue?: T;
+4 -2
View File
@@ -1,14 +1,16 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import classNames from "classnames";
interface Props { interface Props {
className?: string;
contentLength: number; contentLength: number;
contentLengthCompressed?: number | null; contentLengthCompressed?: number | null;
} }
export function SizeTag({ contentLength, contentLengthCompressed }: Props) { export function SizeTag({ className, contentLength, contentLengthCompressed }: Props) {
return ( return (
<span <span
className="font-mono" className={classNames("font-mono", className)}
title={ title={
`${contentLength} bytes` + `${contentLength} bytes` +
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "") (contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "")
@@ -342,7 +342,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
<div <div
className={classNames( className={classNames(
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto", layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
layout === "vertical" && "flex flex-row flex-shrink-0 w-full", layout === "vertical" && "flex flex-row shrink-0 w-full",
)} )}
> >
{tabButtons} {tabButtons}
@@ -456,9 +456,9 @@ function TabButton({
onChangeValue?.(tab.value); onChangeValue?.(tab.value);
}, },
className: classNames( className: classNames(
"flex items-center rounded whitespace-nowrap", "flex items-center rounded-sm whitespace-nowrap",
"!px-2 ml-[1px]", "px-2! ml-px",
"outline-none", "outline-hidden",
"ring-none", "ring-none",
"focus-visible-or-class:outline-2", "focus-visible-or-class:outline-2",
addBorders && "border focus-visible:bg-surface-highlight", addBorders && "border focus-visible:bg-surface-highlight",
@@ -468,7 +468,7 @@ function TabButton({
: layout === "vertical" : layout === "vertical"
? "border-border-subtle" ? "border-border-subtle"
: "border-transparent", : "border-transparent",
layout === "horizontal" && "min-w-[10rem]", layout === "horizontal" && "min-w-40",
isDragging && "opacity-50", isDragging && "opacity-50",
overlay && "opacity-80", overlay && "opacity-80",
), ),
+4 -4
View File
@@ -54,11 +54,11 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
`x-theme-toast x-theme-toast--${color}`, `x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden", "pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg", "relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-[25rem]", "border border-border shadow-lg w-100",
)} )}
> >
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-[11rem] overflow-auto"> <div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 flex-shrink-0" />} {toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0"> <VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div> <div className="select-auto">{children}</div>
{action?.({ hide: onClose })} {action?.({ hide: onClose })}
@@ -68,7 +68,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
<IconButton <IconButton
color={color} color={color}
variant="border" variant="border"
className="opacity-60 border-0 !absolute top-2 right-2" className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss" title="Dismiss"
icon="x" icon="x"
onClick={onClose} onClick={onClose}
+4 -4
View File
@@ -116,7 +116,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
role="button" role="button"
aria-describedby={openState ? id.current : undefined} aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1} tabIndex={tabIndex ?? -1}
className={classNames(className, "flex-grow-0 flex items-center")} className={classNames(className, "grow-0 flex items-center")}
onClick={handleToggleImmediate} onClick={handleToggleImmediate}
onMouseEnter={handleOpen} onMouseEnter={handleOpen}
onMouseLeave={handleClose} onMouseLeave={handleClose}
@@ -141,10 +141,10 @@ function Triangle({ className, position }: { className?: string; position: "top"
shapeRendering="crispEdges" shapeRendering="crispEdges"
className={classNames( className={classNames(
className, className,
"absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]", "absolute z-50 left-[calc(50%-0.4rem)] h-2 w-[0.8rem]",
isBottom isBottom
? "border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2" ? "border-t-2 border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2"
: "border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2", : "border-b-2 border-surface-highlight -top-[calc(0.5rem-3px)] mt-2",
)} )}
> >
<title>Triangle</title> <title>Triangle</title>
@@ -117,7 +117,7 @@ function CommitListItem({
<button <button
type="button" type="button"
className={classNames( className={classNames(
"w-full min-w-0 text-left rounded px-2 py-1.5", "w-full min-w-0 text-left rounded-sm px-2 py-1.5",
selected && "bg-surface-active", selected && "bg-surface-active",
)} )}
onClick={onSelect} onClick={onSelect}
@@ -241,7 +241,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
secondSlot={({ style: innerStyle }) => ( secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"> <div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input <Input
className="!text-base font-sans rounded-md" className="text-base! font-sans rounded-md"
placeholder="Commit message..." placeholder="Commit message..."
onChange={setMessage} onChange={setMessage}
stateKey={null} stateKey={null}
@@ -325,7 +325,7 @@ function TreeNodeChildren({
)} )}
> >
{isSelected && ( {isSelected && (
<div className="absolute -left-[100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" /> <div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)} )}
<Checkbox <Checkbox
checked={checked} checked={checked}
@@ -358,7 +358,7 @@ function TreeNodeChildren({
{node.status.status !== "current" && ( {node.status.status !== "current" && (
<InlineCode <InlineCode
className={classNames( className={classNames(
"py-0 bg-transparent w-[6rem] text-center shrink-0", "py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info", node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success", node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger", node.status.status === "removed" && "text-danger",
@@ -400,7 +400,7 @@ function ExternalTreeNode({
return ( return (
<Checkbox <Checkbox
fullWidth fullWidth
className="h-xs w-full hover:bg-surface-highlight rounded px-1 group" className="h-xs w-full hover:bg-surface-highlight rounded-sm px-1 group"
checked={entry.staged} checked={entry.staged}
onChange={() => onCheck(entry)} onChange={() => onCheck(entry)}
title={ title={
@@ -409,7 +409,7 @@ function ExternalTreeNode({
<div className="truncate">{entry.relaPath}</div> <div className="truncate">{entry.relaPath}</div>
<InlineCode <InlineCode
className={classNames( className={classNames(
"py-0 ml-auto bg-transparent w-[6rem] text-center", "py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info", entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success", entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger", entry.status === "removed" && "text-danger",
@@ -559,7 +559,7 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
ref={ref} ref={ref}
className={classNames( className={classNames(
className, className,
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight", "px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-hidden focus-visible:bg-surface-highlight",
)} )}
{...props} {...props}
/> />
@@ -113,19 +113,19 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
name={{ value: "query", color: "primary" }} name={{ value: "query", color: "primary" }}
item={qryItem} item={qryItem}
setItem={setActiveItem} setItem={setActiveItem}
className="!my-0" className="my-0!"
/> />
<GqlTypeRow <GqlTypeRow
name={{ value: "mutation", color: "primary" }} name={{ value: "mutation", color: "primary" }}
item={mutItem} item={mutItem}
setItem={setActiveItem} setItem={setActiveItem}
className="!my-0" className="my-0!"
/> />
<GqlTypeRow <GqlTypeRow
name={{ value: "subscription", color: "primary" }} name={{ value: "subscription", color: "primary" }}
item={subItem} item={subItem}
setItem={setActiveItem} setItem={setActiveItem}
className="!my-0" className="my-0!"
/> />
<Subheading count={Object.keys(allTypes).length}>All Schema Types</Subheading> <Subheading count={Object.keys(allTypes).length}>All Schema Types</Subheading>
<DocMarkdown>{schema.description ?? null}</DocMarkdown> <DocMarkdown>{schema.description ?? null}</DocMarkdown>
@@ -192,7 +192,7 @@ function GraphQLExplorerHeader({
noTruncate noTruncate
item={crumb} item={crumb}
setItem={setItem} setItem={setItem}
className="!font-sans !text-sm flex-shrink-0" className="font-sans! text-sm! shrink-0"
/> />
)} )}
</Fragment> </Fragment>
@@ -208,7 +208,7 @@ function GraphQLExplorerHeader({
className="hidden @[10rem]:block" className="hidden @[10rem]:block"
/> />
</div> </div>
<div className="ml-auto flex gap-1 [&>*]:text-text-subtle"> <div className="ml-auto flex gap-1 *:text-text-subtle">
<IconButton icon="x" size="sm" title="Close documentation explorer" onClick={onClose} /> <IconButton icon="x" size="sm" title="Close documentation explorer" onClick={onClose} />
</div> </div>
</nav> </nav>
@@ -528,7 +528,7 @@ function GqlTypeRow({
<span className="text-text-subtle">:</span>{" "} <span className="text-text-subtle">:</span>{" "}
<GqlTypeLink color="notice" item={returnItem} setItem={setItem} /> <GqlTypeLink color="notice" item={returnItem} setItem={setItem} />
</div> </div>
<DocMarkdown className="!text-text-subtle mt-0.5"> <DocMarkdown className="text-text-subtle! mt-0.5">
{item.type.description ?? null} {item.type.description ?? null}
</DocMarkdown> </DocMarkdown>
</div> </div>
@@ -786,8 +786,8 @@ function GqlSchemaSearch({
className={classNames( className={classNames(
className, className,
"relative flex items-center bg-surface z-20 min-w-0", "relative flex items-center bg-surface z-20 min-w-0",
!focused && "max-w-[6rem] ml-auto", !focused && "max-w-24 ml-auto",
focused && "!absolute top-0 left-1.5 right-1.5 bottom-0 pt-1.5", focused && "absolute! top-0 left-1.5 right-1.5 bottom-0 pt-1.5",
)} )}
> >
<PlainInput <PlainInput
@@ -879,7 +879,7 @@ function SearchResult({
ref={initRef} ref={initRef}
className={classNames( className={classNames(
className, className,
"px-3 truncate w-full text-left h-sm rounded text-editor font-mono", "px-3 truncate w-full text-left h-sm rounded-sm text-editor font-mono",
isActive && "bg-surface-highlight", isActive && "bg-surface-highlight",
)} )}
{...extraProps} {...extraProps}
@@ -893,7 +893,7 @@ function Heading({ children }: { children: ReactNode }) {
function DocMarkdown({ children, className }: { children: string | null; className?: string }) { function DocMarkdown({ children, className }: { children: string | null; className?: string }) {
return ( return (
<Markdown className={classNames(className, "!text-text-subtle italic")}>{children}</Markdown> <Markdown className={classNames(className, "text-text-subtle! italic")}>{children}</Markdown>
); );
} }
@@ -77,8 +77,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const actions = useMemo<EditorProps["actions"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
<div key="actions" className="flex flex-row !opacity-100 !shadow"> <div key="actions" className="flex flex-row opacity-100! shadow!">
<div key="introspection" className="!opacity-100"> <div key="introspection" className="opacity-100!">
{schema === undefined ? null /* Initializing */ : ( {schema === undefined ? null /* Initializing */ : (
<Dropdown <Dropdown
items={[ items={[
@@ -217,7 +217,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
stateKey={`graphql_body.${request.id}`} stateKey={`graphql_body.${request.id}`}
{...extraEditorProps} {...extraEditorProps}
/> />
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-[5rem]"> <div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-20">
<Separator dashed className="pb-1"> <Separator dashed className="pb-1">
Variables Variables
</Separator> </Separator>
@@ -1,21 +1,38 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource"; import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType"; import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer"; import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow"; import { EventViewerRow } from "../core/EventViewerRow";
import { IconButton } from "../core/IconButton";
import { IconTooltip } from "../core/IconTooltip";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
} }
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) { export function EventStreamViewer({ response }: Props) {
return ( return (
<Fragment <Fragment
@@ -29,9 +46,135 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) { function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false); const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false); const [showingLarge, setShowingLarge] = useState<boolean>(false);
const filterEventPreviewsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_filter_event_previews", response.requestId],
fallback: false,
});
const applyToDetailsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_apply_to_details", response.requestId],
fallback: false,
});
const renderMarkdownSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_render_markdown", response.requestId],
fallback: false,
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response); const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
showExtractedText &&
summary.data != null &&
summary.data.fragmentCount === 0 &&
!summary.isFetching &&
summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
const settingsItems = useMemo<DropdownItem[]>(
() => [
{
label: "Apply to Previews",
keepOpenOnSelect: true,
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
leftSlot: (
<Icon
icon={
filterEventPreviewsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
{
label: "Apply to Details",
keepOpenOnSelect: true,
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
leftSlot: (
<Icon
icon={
applyToDetailsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
return ( return (
<div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
<HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
<div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
<Select
name={`sse-summary-result-key-path-enabled::${response.requestId}`}
label="Extracted text"
hideLabel
size="xs"
value={summarySettings.enabled ? "jsonpath" : "off"}
options={[
{ label: "Full events", value: "off" },
{ label: "JSONPath", value: "jsonpath" },
]}
onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
/>
</div>
{summarySettings.enabled && (
<>
<div className="min-w-40 flex-1">
<Input
label="Result JSON path"
hideLabel
size="xs"
autocomplete={sseSummaryResultKeyPathAutocomplete}
defaultValue={summarySettings.resultKeyPathInputValue}
forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
placeholder="$.choices[0].delta.content"
rightSlot={
showResultKeyPathWarning ? (
<div className="flex items-center px-2">
<IconTooltip
tabIndex={-1}
icon="alert_triangle"
iconColor="notice"
content="No text fragments matched this JSONPath."
/>
</div>
) : null
}
stateKey={`sse-summary-result-key-path::${response.requestId}`}
tint={showResultKeyPathWarning ? "notice" : undefined}
onChange={summarySettings.setResultKeyPath}
/>
</div>
<Dropdown items={settingsItems}>
<IconButton
size="xs"
variant="border"
icon="settings"
title="Extracted text settings"
/>
</Dropdown>
</>
)}
</HStack>
<SplitLayout
layout="vertical"
storageKey={`sse_extracted_text::${response.requestId}`}
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
minHeightPx={72}
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<EventViewer <EventViewer
events={events.data ?? []} events={events.data ?? []}
getEventKey={(_, index) => String(index)} getEventKey={(_, index) => String(index)}
@@ -46,7 +189,9 @@ function ActualEventStreamViewer({ response }: Props) {
content={ content={
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} /> <EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> <span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack> </HStack>
} }
/> />
@@ -55,6 +200,8 @@ function ActualEventStreamViewer({ response }: Props) {
<EventDetail <EventDetail
event={event} event={event}
index={index} index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
showLarge={showLarge} showLarge={showLarge}
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
@@ -63,30 +210,152 @@ function ActualEventStreamViewer({ response }: Props) {
/> />
)} )}
/> />
</div>
)}
secondSlot={
showExtractedText
? ({ style }) => (
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
)
: null
}
/>
</div>
); );
} }
function SseSummaryFooter({
error,
fragmentCount,
isLoading,
onRenderMarkdownChange,
renderMarkdown,
resultKeyPath,
style,
summary,
}: {
error: string | null;
fragmentCount: number;
isLoading: boolean;
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
renderMarkdown: boolean;
resultKeyPath: string;
style: CSSProperties;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const actions = useMemo(
() => [
{
key: "sse-summary-format",
label: "Extracted text format",
type: "select" as const,
value: renderMarkdown ? "markdown" : "text",
options: [
{ label: "Text", value: "text" },
{ label: "Markdown", value: "markdown" },
],
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
},
],
[onRenderMarkdownChange, renderMarkdown],
);
return (
<div
style={style}
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
>
<div className="pt-2">
<EventDetailHeader
actions={actions}
title="Extracted Text"
copyText={hasSummary ? summary : undefined}
/>
</div>
<div
className={classNames(
"min-h-0 py-2 overflow-auto",
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
)}
>
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading extracted text...</span>
) : hasSummary ? (
renderMarkdown ? (
<div className="min-h-0">
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
</div>
) : (
<pre className="font-mono whitespace-pre-wrap wrap-break-word select-auto cursor-auto">
{summary}
</pre>
)
) : (
<EmptyStateText className="gap-1.5">
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</EmptyStateText>
)}
</div>
</div>
);
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
filterEventPreview: boolean,
): string {
if (filterEventPreview && resultKeyPath != null) {
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
}
return event.data.slice(0, 1000);
}
function EventDetail({ function EventDetail({
applyJsonPath,
event, event,
index, index,
resultKeyPath,
showLarge, showLarge,
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose, onClose,
}: { }: {
applyJsonPath: boolean;
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
resultKeyPath: string | null;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const detailText = useMemo(
() =>
applyJsonPath && resultKeyPath != null
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
: event.data,
[applyJsonPath, event.data, resultKeyPath],
);
const language = useMemo<"text" | "json">(() => { const language = useMemo<"text" | "json">(() => {
if (!event?.data) return "text"; if (!detailText) return "text";
return isJSON(event?.data) ? "json" : "text"; return isJSON(detailText) ? "json" : "text";
}, [event?.data]); }, [detailText]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -95,7 +364,7 @@ function EventDetail({
prefix={<EventLabels event={event} index={index} />} prefix={<EventLabels event={event} index={index} />}
onClose={onClose} onClose={onClose}
/> />
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && detailText.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
<div> <div>
@@ -117,7 +386,7 @@ function EventDetail({
</div> </div>
</VStack> </VStack>
) : ( ) : (
<FormattedEditor language={language} text={event.data} /> <FormattedEditor language={language} text={detailText} />
)} )}
</div> </div>
); );
@@ -142,14 +411,17 @@ function EventLabels({
}) { }) {
return ( return (
<HStack space={1.5} alignItems="center" className={className}> <HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}> <EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.id ?? index} {event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.eventType}
</InlineCode>
)}
</HStack> </HStack>
); );
} }
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
<span className="relative">{children}</span>
</InlineCode>
);
}
@@ -1,9 +1,12 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText"; import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType"; import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util"; import { getContentTypeFromHeaders } from "../../lib/model_util";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { IconButton } from "../core/IconButton";
import { EmptyStateText } from "../EmptyStateText"; import { EmptyStateText } from "../EmptyStateText";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { WebPageViewer } from "./WebPageViewer"; import { WebPageViewer } from "./WebPageViewer";
@@ -51,6 +54,9 @@ interface HttpTextViewerProps {
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null); const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter }); const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
const filterCallback = useMemo( const filterCallback = useMemo(
() => (filter: string) => { () => (filter: string) => {
@@ -72,6 +78,26 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
filterStateKey={`response.body.${response.requestId}`} filterStateKey={`response.body.${response.requestId}`}
pretty={pretty} pretty={pretty}
className={className} className={className}
footerActions={[
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
]}
onFilter={filterCallback} onFilter={filterCallback}
/> />
); );
@@ -63,7 +63,7 @@ export function MultipartViewer({ data, boundary, idPrefix = "multipart" }: Prop
<div className="h-5 w-5 overflow-auto flex items-center justify-end"> <div className="h-5 w-5 overflow-auto flex items-center justify-end">
<ImageViewer <ImageViewer
data={part.arrayBuffer} data={part.arrayBuffer}
className="ml-auto w-auto rounded overflow-hidden" className="ml-auto w-auto rounded-sm overflow-hidden"
/> />
</div> </div>
) : part.filename ? ( ) : part.filename ? (
@@ -76,7 +76,7 @@ export function MultipartViewer({ data, boundary, idPrefix = "multipart" }: Prop
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on // oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
key={idPrefix + part.name + i} key={idPrefix + part.name + i}
value={tabValue(part, i)} value={tabValue(part, i)}
className="pl-3 !pt-0" className="pl-3 pt-0!"
> >
<Part part={part} /> <Part part={part} />
</TabContent> </TabContent>

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