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