mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-02 11:01:36 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5004c395de | |||
| ea3587f28d | |||
| 24e578db5f | |||
| 12562aa076 | |||
| 5a74a989b5 | |||
| a6558329e2 | |||
| 54a931d94d | |||
| 5229534d8f | |||
| 78b3996f47 | |||
| d9f7bf7fdd | |||
| 45c410dd4c | |||
| 80e56281b2 | |||
| 125eae052b | |||
| 6f52bb7533 | |||
| 8724260eb4 | |||
| f32e9f7704 | |||
| 83c8371e94 | |||
| 5f14d90ccd | |||
| ff0d8c03b0 | |||
| 1dd7e728ff |
@@ -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
-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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 break-words select-auto cursor-auto">
|
||||
{summary}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<EmptyStateText className="gap-1.5">
|
||||
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
|
||||
</EmptyStateText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventPreview(
|
||||
event: ServerSentEvent,
|
||||
resultKeyPath: string | null,
|
||||
filterEventPreview: boolean,
|
||||
): string {
|
||||
if (filterEventPreview && resultKeyPath != null) {
|
||||
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
|
||||
}
|
||||
|
||||
return event.data.slice(0, 1000);
|
||||
}
|
||||
|
||||
function EventDetail({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { getResponseBodyEventSource } from "../lib/responseBody";
|
||||
export function useResponseBodyEventSource(response: HttpResponse) {
|
||||
return useQuery<ServerSentEvent[]>({
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: ["response-body-event-source", response.id, response.contentLength],
|
||||
queryKey: [
|
||||
"response-body-event-source",
|
||||
response.id,
|
||||
response.updatedAt,
|
||||
response.contentLength,
|
||||
],
|
||||
queryFn: () => getResponseBodyEventSource(response),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { SseSummary } from "@yaakapp-internal/sse";
|
||||
import { getResponseBodySseSummary } from "../lib/responseBody";
|
||||
|
||||
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
|
||||
return useQuery<SseSummary>({
|
||||
enabled: resultKeyPath != null,
|
||||
queryKey: [
|
||||
"response-body-sse-summary",
|
||||
response.id,
|
||||
response.updatedAt,
|
||||
response.contentLength,
|
||||
resultKeyPath,
|
||||
],
|
||||
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { useMemo } from "react";
|
||||
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
|
||||
import { useKeyValue } from "./useKeyValue";
|
||||
|
||||
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
|
||||
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
|
||||
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
|
||||
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
|
||||
|
||||
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
|
||||
{
|
||||
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
|
||||
detail: "ChatGPT (OpenAI)",
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
{
|
||||
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
|
||||
detail: "Responses (OpenAI)",
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
{
|
||||
label: ANTHROPIC_RESULT_KEY_PATH,
|
||||
detail: "Claude (Anthropic)",
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
{
|
||||
label: GOOGLE_RESULT_KEY_PATH,
|
||||
detail: "Gemini (Google)",
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
|
||||
minMatch: 0,
|
||||
options: sseSummaryResultKeyPathOptions,
|
||||
};
|
||||
|
||||
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
|
||||
const storedResultKeyPath = useKeyValue<string | null>({
|
||||
namespace: "no_sync",
|
||||
key: ["sse_summary_result_key_path", response.requestId],
|
||||
fallback: null,
|
||||
});
|
||||
const enabled = useKeyValue<boolean | null>({
|
||||
namespace: "no_sync",
|
||||
key: ["sse_summary_result_key_path_enabled", response.requestId],
|
||||
fallback: null,
|
||||
});
|
||||
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
|
||||
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
|
||||
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
|
||||
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
|
||||
|
||||
return {
|
||||
enabled: isEnabled,
|
||||
inferredResultKeyPath,
|
||||
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
|
||||
resultKeyPathInputValue: resultKeyPath ?? "",
|
||||
setEnabled: enabled.set,
|
||||
setResultKeyPath: storedResultKeyPath.set,
|
||||
};
|
||||
}
|
||||
|
||||
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(response.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const pathname = url.pathname.toLowerCase();
|
||||
|
||||
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
|
||||
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
|
||||
}
|
||||
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
|
||||
return OPENAI_RESPONSES_RESULT_KEY_PATH;
|
||||
}
|
||||
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
|
||||
return ANTHROPIC_RESULT_KEY_PATH;
|
||||
}
|
||||
if (
|
||||
hostname === "generativelanguage.googleapis.com" &&
|
||||
pathname.includes(":streamgeneratecontent")
|
||||
) {
|
||||
return GOOGLE_RESULT_KEY_PATH;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
||||
|
||||
describe("extractPathPlaceholders", () => {
|
||||
test("extracts a single placeholder", () => {
|
||||
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("extracts multiple placeholders", () => {
|
||||
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
|
||||
});
|
||||
|
||||
test("stops at a literal `:` in the same segment", () => {
|
||||
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("does not match `:foo` mid-segment", () => {
|
||||
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not match `:` in a host port", () => {
|
||||
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("returns empty for a URL with no placeholders", () => {
|
||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Extract `:name`-style path placeholders from a URL string.
|
||||
*
|
||||
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
|
||||
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
|
||||
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
|
||||
* placeholder `:id` and `:increment-importance` is literal text.
|
||||
*
|
||||
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
|
||||
* has no placeholders. Returned names include the leading colon.
|
||||
*/
|
||||
export function extractPathPlaceholders(url: string): string[] {
|
||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { FilterResponse } from "@yaakapp-internal/plugins";
|
||||
import type { ServerSentEvent } from "@yaakapp-internal/sse";
|
||||
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
|
||||
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
export async function getResponseBodyText({
|
||||
@@ -27,9 +28,36 @@ export async function getResponseBodyEventSource(
|
||||
response: HttpResponse,
|
||||
): Promise<ServerSentEvent[]> {
|
||||
if (!response.bodyPath) return [];
|
||||
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
filePath: response.bodyPath,
|
||||
});
|
||||
try {
|
||||
const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
filePath: response.bodyPath,
|
||||
});
|
||||
if (events.length > 0) {
|
||||
return events;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
||||
}
|
||||
|
||||
const bytes = await readFile(response.bodyPath);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
||||
data,
|
||||
eventType: "",
|
||||
id: String(index),
|
||||
retry: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getResponseBodySseSummary(
|
||||
response: HttpResponse,
|
||||
resultKeyPath: string,
|
||||
): Promise<SseSummary> {
|
||||
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
|
||||
|
||||
const bytes = await readFile(response.bodyPath);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
return computeSseSummary(text, resultKeyPath);
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
|
||||
@@ -34,7 +34,10 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
|
||||
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
|
||||
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
|
||||
// placeholder while leaving `:increment-importance` as literal text.
|
||||
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
|
||||
let result = re
|
||||
.replace_all(url, |cap: ®ex::Captures| {
|
||||
format!(
|
||||
@@ -83,6 +86,18 @@ mod placeholder_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_followed_by_literal_colon() {
|
||||
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
|
||||
// is literal text in the same path segment.
|
||||
let p =
|
||||
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
|
||||
"https://example.com/tasks/42:increment-importance",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_missing() {
|
||||
let p = HttpUrlParameter {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./bindings/sse";
|
||||
export * from "./summary";
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
"name": "@yaakapp-internal/sse",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0"
|
||||
},
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSseSummary, extractSseValueAtPath } from "./summary";
|
||||
|
||||
describe("extractSseValueAtPath", () => {
|
||||
it("supports simple paths", () => {
|
||||
expect(
|
||||
extractSseValueAtPath(
|
||||
JSON.stringify({ choices: [{ delta: { content: "hello" } }] }),
|
||||
"$.choices[0].delta.content",
|
||||
),
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("supports full JSONPath expressions", () => {
|
||||
expect(
|
||||
extractSseValueAtPath(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{ delta: { role: "assistant" } },
|
||||
{ delta: { content: "hello" } },
|
||||
{ delta: { content: " world" } },
|
||||
],
|
||||
}),
|
||||
"$.choices[*].delta.content",
|
||||
),
|
||||
).toBe("hello world");
|
||||
});
|
||||
|
||||
it("returns null when a JSONPath expression has no matches", () => {
|
||||
expect(extractSseValueAtPath(JSON.stringify({ delta: {} }), "$.delta.text")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSseSummary", () => {
|
||||
it("concatenates JSONPath matches across SSE messages", () => {
|
||||
expect(
|
||||
computeSseSummary(
|
||||
[
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: " world" } }] })}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
"$.choices[*].delta.content",
|
||||
),
|
||||
).toEqual({
|
||||
fragmentCount: 2,
|
||||
summary: "hello world",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
export interface SseSummary {
|
||||
fragmentCount: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
type JSONPathJson = null | boolean | number | string | object | unknown[];
|
||||
|
||||
const STANDARD_SSE_FIELD = /^(event|id|retry):/i;
|
||||
|
||||
export function candidateJsonPayloadsFromSseText(text: string): string[] {
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const blocks = normalized.split(/\n{2,}/);
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split("\n");
|
||||
const dataLines = lines
|
||||
.map((line) => {
|
||||
const match = /^data:(?: ?)(.*)$/.exec(line);
|
||||
return match?.[1];
|
||||
})
|
||||
.filter((line): line is string => line != null);
|
||||
|
||||
if (dataLines.length > 0) {
|
||||
const payload = dataLines.join("\n").trim();
|
||||
if (payload) {
|
||||
candidates.push(payload);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedBlock = block.trim();
|
||||
if (!trimmedBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isParsableJson(trimmedBlock)) {
|
||||
candidates.push(trimmedBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (
|
||||
!trimmedLine ||
|
||||
trimmedLine.startsWith(":") ||
|
||||
STANDARD_SSE_FIELD.test(trimmedLine) ||
|
||||
!isParsableJson(trimmedLine)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(trimmedLine);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function computeSseSummary(text: string, keyPath: string): SseSummary {
|
||||
const fragments: string[] = [];
|
||||
|
||||
for (const payload of candidateJsonPayloadsFromSseText(text)) {
|
||||
const fragment = extractSseValueAtPath(payload, keyPath);
|
||||
if (fragment != null) {
|
||||
fragments.push(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fragmentCount: fragments.length,
|
||||
summary: fragments.join(""),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractSseValueAtPath(payload: string, keyPath: string): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = keyPath.trim();
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
result = JSONPath({ path, json: parsed as JSONPathJson });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const fragments = result
|
||||
.map((item) => stringifySummaryValue(item))
|
||||
.filter((item): item is string => item != null);
|
||||
return fragments.length > 0 ? fragments.join("") : null;
|
||||
}
|
||||
|
||||
return stringifySummaryValue(result);
|
||||
}
|
||||
|
||||
function stringifySummaryValue(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isParsableJson(value: string): boolean {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Generated
+4
-1
@@ -356,7 +356,10 @@
|
||||
},
|
||||
"crates/yaak-sse": {
|
||||
"name": "@yaakapp-internal/sse",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^10.3.0"
|
||||
}
|
||||
},
|
||||
"crates/yaak-sync": {
|
||||
"name": "@yaakapp-internal/sync",
|
||||
|
||||
@@ -27,7 +27,7 @@ interface Props {
|
||||
resizeHandleClassName?: string;
|
||||
}
|
||||
|
||||
const baseProperties = { minWidth: 0 };
|
||||
const baseProperties = { minHeight: 0, minWidth: 0 };
|
||||
const areaL = { ...baseProperties, gridArea: "left" };
|
||||
const areaR = { ...baseProperties, gridArea: "right" };
|
||||
const areaD = { ...baseProperties, gridArea: "drag" };
|
||||
@@ -60,23 +60,25 @@ export function SplitLayout({
|
||||
const size = useContainerSize(containerRef);
|
||||
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
|
||||
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
|
||||
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
|
||||
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
|
||||
|
||||
const styles = useMemo<CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
|
||||
' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
|
||||
' ${areaD.gridArea}' 0
|
||||
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
|
||||
' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - width}fr 0 ${width}fr
|
||||
/ ${1 - renderedWidth}fr 0 ${renderedWidth}fr
|
||||
`,
|
||||
};
|
||||
}, [style, vertical, height, minHeightPx, width]);
|
||||
}, [style, vertical, renderedHeight, renderedWidth]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
@@ -96,22 +98,36 @@ export function SplitLayout({
|
||||
const containerHeight =
|
||||
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
|
||||
|
||||
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseStartX = e.xStart;
|
||||
const mouseStartY = e.yStart;
|
||||
const startWidth = containerWidth * width;
|
||||
const startHeight = containerHeight * height;
|
||||
const startWidth = containerWidth * renderedWidth;
|
||||
const startHeight = containerHeight * renderedHeight;
|
||||
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerHeight - minHeightPx;
|
||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
|
||||
const minHeight = Math.min(minHeightPx, containerHeight);
|
||||
const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
|
||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
|
||||
setHeight(newHeightPx / containerHeight);
|
||||
} else {
|
||||
const maxWidthPx = containerWidth - minWidthPx;
|
||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
|
||||
const minWidth = Math.min(minWidthPx, containerWidth);
|
||||
const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
|
||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
|
||||
setWidth(newWidthPx / containerWidth);
|
||||
}
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
[
|
||||
renderedWidth,
|
||||
renderedHeight,
|
||||
vertical,
|
||||
minHeightPx,
|
||||
setHeight,
|
||||
minWidthPx,
|
||||
setWidth,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -140,3 +156,13 @@ export function SplitLayout({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
|
||||
if (containerPx <= 0 || minPx <= 0) {
|
||||
return ratio;
|
||||
}
|
||||
|
||||
const minRatio = Math.min(1, minPx / containerPx);
|
||||
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
|
||||
return clamp(ratio, minRatio, maxRatio);
|
||||
}
|
||||
|
||||
@@ -180,8 +180,12 @@ function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters">
|
||||
v += `:${url.port}`;
|
||||
}
|
||||
|
||||
if ("path" in url && Array.isArray(url.path) && url.path.length > 0) {
|
||||
v += `/${Array.isArray(url.path) ? url.path.join("/") : url.path}`;
|
||||
if ("path" in url) {
|
||||
if (Array.isArray(url.path) && url.path.length > 0) {
|
||||
v += `/${url.path.join("/")}`;
|
||||
} else if (typeof url.path === "string" && url.path.length > 0) {
|
||||
v += `/${url.path.replace(/^\//, "")}`;
|
||||
}
|
||||
}
|
||||
|
||||
const params: HttpUrlParameter[] = [];
|
||||
|
||||
@@ -57,4 +57,34 @@ describe("importer-postman", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports url.path when it is a string instead of an array", () => {
|
||||
const result = convertPostman(
|
||||
JSON.stringify({
|
||||
info: {
|
||||
name: "String Path Test",
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: "String Path",
|
||||
request: {
|
||||
method: "GET",
|
||||
url: {
|
||||
host: ["example", "com"],
|
||||
path: "foo/bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "String Path",
|
||||
url: "example.com/foo/bar",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user