Compare commits

...

19 Commits

Author SHA1 Message Date
Simon Johansson 5004c395de fix: Resolve : ambiguity in URL path placeholders (#465)
Co-authored-by: Simon Johansson <simon.johansson@infor.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 13:23:27 -07:00
Gregory Schier ea3587f28d Fix commercial use banner snooze 2026-07-01 12:44:44 -07:00
baofeidyz 24e578db5f Add SSE response summary helpers (#466)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-07-01 12:33:03 -07:00
Gregory Schier 12562aa076 Skip older PRs in contribution policy automation 2026-07-01 11:53:38 -07:00
Joris van Eijden 5a74a989b5 Support string-based url.path in Postman importer. (#490) 2026-07-01 11:47:53 -07:00
Gregory Schier a6558329e2 Run contribution policy on PR updates 2026-07-01 11:01:15 -07:00
Gregory Schier 54a931d94d Require screenshots confirmation in PR template 2026-06-30 15:24:38 -07:00
Gregory Schier 5229534d8f Clarify blocked contribution labels 2026-06-30 15:16:28 -07:00
Gregory Schier 78b3996f47 Limit community PR policy to bug fixes 2026-06-30 15:05:31 -07:00
Gregory Schier d9f7bf7fdd Remove PR body note from policy comments 2026-06-30 15:00:54 -07:00
Gregory Schier 45c410dd4c Clarify contribution policy blocker wording 2026-06-30 14:57:29 -07:00
Gregory Schier 80e56281b2 Include PR template in policy comment 2026-06-30 14:45:44 -07:00
Gregory Schier 125eae052b Shorten contribution policy inputs 2026-06-30 14:40:15 -07:00
Gregory Schier 6f52bb7533 Clarify explicit contribution permission 2026-06-30 14:36:46 -07:00
Gregory Schier 8724260eb4 Allow targeted contribution policy runs 2026-06-30 14:25:41 -07:00
Gregory Schier f32e9f7704 Refine contribution policy labels 2026-06-30 14:20:13 -07:00
Gregory Schier 83c8371e94 Support contribution policy label overrides 2026-06-30 14:15:37 -07:00
Gregory Schier 5f14d90ccd Preview contribution policy comments 2026-06-30 13:54:45 -07:00
Gregory Schier ff0d8c03b0 Improve contribution policy summary 2026-06-30 13:41:02 -07:00
31 changed files with 1378 additions and 253 deletions
+4 -3
View File
@@ -4,13 +4,14 @@
## Submission ## Submission
- [ ] This PR is a bug fix or small-scope improvement. - [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below. - [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement): Explicit permission feedback item (required if not a bug fix):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
+369 -87
View File
@@ -1,51 +1,69 @@
const fs = require("node:fs");
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->"; const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]); const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]); const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20; const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800; const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const LABELS = { const LABELS = {
accepted: { inScope: {
name: "contribution: accepted", name: "contribution: in scope",
color: "0E8A16", color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.", description: "Community PR appears to be in scope for maintainer review.",
}, },
approvedFeedback: { outOfScope: {
name: "contribution: approved feedback", name: "contribution: out of scope",
color: "5319E7",
description: "Community PR links an approved feedback item.",
},
needsTemplate: {
name: "contribution: needs template",
color: "D93F0B",
description: "Community PR needs a completed pull request template.",
},
needsApproval: {
name: "contribution: needs approval",
color: "B60205", color: "B60205",
description: "Community PR needs an approved feedback item before review.", description: "Community PR does not match Yaak's contribution policy.",
}, },
largeDiff: { explicitPermission: {
name: "contribution: large diff", name: "contribution: explicit permission",
color: "5319E7",
description:
"Community PR links feedback where @gschier explicitly allowed the work.",
},
missingTemplate: {
name: "contribution: missing template",
color: "D93F0B",
description:
"Community PR is missing enough of the pull request template to review.",
},
policyUnmet: {
name: "contribution: policy unmet",
color: "B60205",
description:
"Community PR does not currently satisfy the contribution policy.",
},
needsScopeReview: {
name: "contribution: needs scope review",
color: "FBCA04", color: "FBCA04",
description: description:
"Community PR has a larger-than-usual diff for a small-scope contribution.", "Community PR may be broader than Yaak's bug-fix contribution policy.",
}, },
}; };
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name); const MANAGED_LABEL_NAMES = [
...new Set(Object.values(LABELS).map((label) => label.name)),
];
const CHECKBOXES = { const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.", bugFix: "This PR is a bug fix.",
approvedFeedback: explicitPermission:
"If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.", "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
readContributing: readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).", "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.", testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.", testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
"I added screenshots or recordings for UI changes when reasonable.",
}; };
function escapeRegExp(value) { function escapeRegExp(value) {
@@ -77,19 +95,30 @@ function hasMeaningfulText(value) {
return stripComments(value || "").length > 0; return stripComments(value || "").length > 0;
} }
function checkboxState(body, label) { function normalizeCheckboxLabel(label) {
const flexibleLabel = escapeRegExp(label).replace(/\\ /g, "\\s+"); return label
const pattern = new RegExp( .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
`^\\s*[-*]\\s*\\[([ xX])\\]\\s*${flexibleLabel}\\s*$`, .replace(/`/g, "")
"im", .replace(/\s+/g, " ")
); .trim();
const match = body.match(pattern); }
if (match == null) { function checkboxState(body, label) {
return null; const expectedLabel = normalizeCheckboxLabel(label);
for (const line of body.split("\n")) {
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
if (match == null) {
continue;
}
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
return match[1].toLowerCase() === "x";
}
} }
return match[1].toLowerCase() === "x"; return null;
} }
function findFeedbackUrl(body) { function findFeedbackUrl(body) {
@@ -100,8 +129,13 @@ function findFeedbackUrl(body) {
); );
} }
function getLabelNames(pr) {
return new Set((pr.labels || []).map((label) => label.name));
}
function analyzePullRequest(pr) { function analyzePullRequest(pr) {
const body = normalizeBody(pr.body); const body = normalizeBody(pr.body);
const labelNames = getLabelNames(pr);
const states = Object.fromEntries( const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [ Object.entries(CHECKBOXES).map(([key, label]) => [
key, key,
@@ -123,9 +157,38 @@ function analyzePullRequest(pr) {
changedFiles > LARGE_DIFF_CHANGED_FILES || changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES; totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (labelNames.has(LABELS.outOfScope.name)) {
return {
blockers: [
{
label: LABELS.outOfScope.name,
message: "Marked out of scope by maintainer label.",
},
],
changedFiles,
desiredLabels: [LABELS.outOfScope.name],
largeDiff,
status: "out_of_scope",
templateUsed,
totalChangedLines,
};
}
if (labelNames.has(LABELS.inScope.name)) {
return {
blockers: [],
changedFiles,
desiredLabels: [LABELS.inScope.name],
largeDiff,
status: "in_scope",
templateUsed,
totalChangedLines,
};
}
if (!templateUsed) { if (!templateUsed) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.missingTemplate.name,
message: message:
"Update the PR description with the repository pull request template.", "Update the PR description with the repository pull request template.",
}); });
@@ -133,70 +196,83 @@ function analyzePullRequest(pr) {
const summary = getSection(body, "Summary"); const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary); const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body); const feedbackUrl = findFeedbackUrl(body);
const smallScope = states.smallScope === true; const bugFix = states.bugFix === true;
const approvedFeedback = states.approvedFeedback === true; const explicitPermission = states.explicitPermission === true;
if (!hasSummary) { if (!hasSummary) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Add a short summary describing the bug fix or improvement.", message:
"Add a short summary describing the bug fix or permitted change.",
}); });
} }
if (smallScope && approvedFeedback) { if (bugFix && explicitPermission) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: message:
"Choose either the small-scope checkbox or the approved-feedback checkbox, not both.", "Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
}); });
} else if (!smallScope && !approvedFeedback) { } else if (!bugFix && !explicitPermission) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: message:
"Check whether this is a bug fix or small-scope improvement, or confirm that an approved feedback item is linked.", "Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
}); });
} else if (approvedFeedback && feedbackUrl == null) { } else if (explicitPermission && feedbackUrl == null) {
blockers.push({ blockers.push({
label: LABELS.needsApproval.name, label: LABELS.policyUnmet.name,
message: message:
"Link the approved feedback item where contribution approval was explicitly stated.", "Link the feedback item where @gschier explicitly gave you permission to work on this.",
}); });
} }
if (states.readContributing !== true) { if (states.readContributing !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.", message: "Confirm that `CONTRIBUTING.md` was read and followed.",
}); });
} }
if (states.testedLocally !== true) { if (states.testedLocally !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that the change was tested locally.", message: "Confirm that the change was tested locally.",
}); });
} }
if (states.testsUpdated !== true) { if (states.testsUpdated !== true) {
blockers.push({ blockers.push({
label: LABELS.needsTemplate.name, label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.", message: "Confirm that tests were added or updated when reasonable.",
}); });
} }
if (states.screenshotsAdded !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
});
}
} }
const desiredLabels = new Set(blockers.map((blocker) => blocker.label)); const desiredLabels = new Set();
if (blockers.length === 0) { if (blockers.length === 0) {
desiredLabels.add( desiredLabels.add(
states.approvedFeedback largeDiff
? LABELS.approvedFeedback.name ? LABELS.needsScopeReview.name
: LABELS.accepted.name, : states.explicitPermission
? LABELS.explicitPermission.name
: LABELS.inScope.name,
); );
} } else if (
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
if (largeDiff) { ) {
desiredLabels.add(LABELS.largeDiff.name); desiredLabels.add(LABELS.missingTemplate.name);
} else {
desiredLabels.add(LABELS.policyUnmet.name);
} }
return { return {
@@ -204,6 +280,7 @@ function analyzePullRequest(pr) {
changedFiles, changedFiles,
desiredLabels: [...desiredLabels], desiredLabels: [...desiredLabels],
largeDiff, largeDiff,
status: blockers.length === 0 ? "in_scope" : "blocked",
templateUsed, templateUsed,
totalChangedLines, totalChangedLines,
}; };
@@ -212,43 +289,147 @@ function analyzePullRequest(pr) {
function buildBlockingComment(analysis) { function buildBlockingComment(analysis) {
const lines = [ const lines = [
COMMENT_MARKER, COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes and small-scope improvements, plus larger changes that link an approved feedback item from https://yaak.app/feedback.", "Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
"", "",
"This PR cannot be accepted yet. Please update the PR description to address:", "This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
"", "",
...analysis.blockers.map((blocker) => `- ${blocker.message}`), ...analysis.blockers.map((blocker) => `- ${blocker.message}`),
]; ];
if (analysis.largeDiff) { if (!analysis.templateUsed) {
lines.push( lines.push(
"", "",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as a large diff. That label is advisory, but maintainers may ask for the scope to be reduced.`, "You can copy this template into the PR description and keep any existing context that is still useful.",
"",
"<details>",
"<summary>PR description template</summary>",
"",
"```md",
getPullRequestTemplate(),
"```",
"",
"</details>",
); );
} }
lines.push( if (analysis.largeDiff) {
"", lines.push(
"I did not overwrite the PR body, since that can remove useful context. Editing the description directly is the safest way to keep your notes while completing the template.", "",
); `This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
);
}
return lines.join("\n"); return lines.join("\n");
} }
function summarizeResult({ pr, analysis, skipped, skipReason }) { function getPullRequestTemplate() {
if (skipped) { return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
return `#${pr.number} ${pr.title} - skipped (${skipReason})`; }
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();
} }
const status = if (analysis.blockers.length > 0) {
analysis.blockers.length > 0 return buildBlockingComment(analysis);
? `blocked: ${analysis.blockers.map((blocker) => blocker.message).join("; ")}` }
: "accepted";
const labels =
analysis.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "none";
return `#${pr.number} ${pr.title} - ${status}; labels: ${labels}`; return buildInScopeComment();
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function truncateTitle(title) {
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
}
function escapeTableText(value) {
return escapeHtml(value).replace(/\n/g, "<br>");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
const comment =
analysis == null
? "None"
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
const summary = {
blocked: analysis?.blockers.length > 0,
comment,
details: "None",
labels:
analysis?.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "None",
number: pr.number,
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
status: "In scope",
title: escapeHtml(truncateTitle(pr.title)),
};
if (skipped) {
return {
...summary,
blocked: false,
comment: "None",
details: escapeHtml(skipReason),
labels: "None",
status: "Skipped",
};
}
if (summary.blocked) {
return {
...summary,
comment: escapeTableText(summary.comment),
details: escapeHtml(
analysis.blockers.map((blocker) => blocker.message).join("; "),
),
labels: escapeHtml(summary.labels),
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
};
}
return {
...summary,
comment: escapeTableText(summary.comment),
labels: escapeHtml(summary.labels),
};
}
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
} }
async function isOfficialMaintainer({ github, owner, repo, pr }) { async function isOfficialMaintainer({ github, owner, repo, pr }) {
@@ -394,6 +575,27 @@ async function deletePolicyComment({ github, owner, repo, issueNumber }) {
}); });
} }
async function requestMaintainerReview({ github, owner, repo, pr }) {
if (pr.user.login === REVIEWER_LOGIN) {
return;
}
try {
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pr.number,
reviewers: [REVIEWER_LOGIN],
});
} catch (error) {
if (error.status === 422) {
return;
}
throw error;
}
}
async function checkPullRequest({ async function checkPullRequest({
github, github,
core, core,
@@ -401,6 +603,7 @@ async function checkPullRequest({
repo, repo,
pullNumber, pullNumber,
dryRun, dryRun,
skipCreatedBefore,
}) { }) {
const response = await github.rest.pulls.get({ const response = await github.rest.pulls.get({
owner, owner,
@@ -410,6 +613,25 @@ async function checkPullRequest({
const pr = response.data; const pr = response.data;
const issueNumber = pr.number; const issueNumber = pr.number;
if (
skipCreatedBefore != null &&
wasCreatedBefore(pr.created_at, skipCreatedBefore)
) {
core.notice(
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
}),
skipped: true,
};
}
if (pr.draft) { if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`); core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return { return {
@@ -448,7 +670,9 @@ async function checkPullRequest({
if (dryRun) { if (dryRun) {
const summary = summarizeResult({ pr, analysis }); const summary = summarizeResult({ pr, analysis });
core.notice(`[dry-run] ${summary}`); core.notice(
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
);
return { return {
blocked: analysis.blockers.length > 0, blocked: analysis.blockers.length > 0,
number: pr.number, number: pr.number,
@@ -471,7 +695,7 @@ async function checkPullRequest({
owner, owner,
repo, repo,
issueNumber, issueNumber,
body: buildBlockingComment(analysis), body: buildPolicyComment(analysis),
}); });
return { return {
blocked: true, blocked: true,
@@ -481,7 +705,14 @@ async function checkPullRequest({
}; };
} }
await deletePolicyComment({ github, owner, repo, issueNumber }); await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
await requestMaintainerReview({ github, owner, repo, pr });
core.notice(`Contribution policy check passed for PR #${pr.number}.`); core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return { return {
blocked: false, blocked: false,
@@ -500,20 +731,59 @@ async function listOpenPullRequests({ github, owner, repo }) {
}); });
} }
function getManualPullRequestNumbers({ context, core }) {
const value = String(context.payload.inputs?.pr || "all").trim();
if (value.toLowerCase() === "all") {
return null;
}
const pullNumber = Number(value);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
core.setFailed('The "pr" input must be "all" or a positive PR number.');
return [];
}
return [pullNumber];
}
async function run({ github, context, core }) { async function run({ github, context, core }) {
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request; const payloadPr = context.payload.pull_request;
const dryRunInput = context.payload.inputs?.dry_run;
const dryRun = const dryRun =
context.eventName === "workflow_dispatch" && context.eventName === "workflow_dispatch" &&
context.payload.inputs?.dry_run !== "false"; dryRunInput !== false &&
dryRunInput !== "false";
const skipCreatedBefore =
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
let pullNumbers;
if (payloadPr != null) {
pullNumbers = [payloadPr.number];
} else {
pullNumbers = getManualPullRequestNumbers({ context, core });
}
if (pullNumbers?.length === 0) {
return;
}
const pullRequests = const pullRequests =
payloadPr == null pullNumbers == null
? await listOpenPullRequests({ github, owner, repo }) ? await listOpenPullRequests({ github, owner, repo })
: [payloadPr]; : pullNumbers.map((number) => ({ number }));
const results = []; const results = [];
if (dryRun) { if (dryRun) {
core.notice("Running contribution policy in dry-run mode."); core.notice(
`Running contribution policy in dry-run mode for ${
pullNumbers == null
? "all open PRs"
: pullNumbers.map((number) => `#${number}`).join(", ")
}.`,
);
} }
for (const pr of pullRequests) { for (const pr of pullRequests) {
@@ -525,6 +795,7 @@ async function run({ github, context, core }) {
repo, repo,
pullNumber: pr.number, pullNumber: pr.number,
dryRun, dryRun,
skipCreatedBefore,
}), }),
); );
} }
@@ -534,9 +805,20 @@ async function run({ github, context, core }) {
.addTable([ .addTable([
[ [
{ data: "PR", header: true }, { data: "PR", header: true },
{ data: "Result", header: true }, { data: "Title", header: true },
{ data: "Status", header: true },
{ data: "Labels", header: true },
{ data: "Details", header: true },
{ data: "Comment", header: true },
], ],
...results.map((result) => [`#${result.number}`, result.summary]), ...results.map((result) => [
result.summary.prLink,
result.summary.title,
result.summary.status,
result.summary.labels,
result.summary.details,
result.summary.comment,
]),
]) ])
.write(); .write();
+17 -2
View File
@@ -3,16 +3,30 @@ name: Contribution Policy
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
pr:
description: PR number or all
required: true
default: all
type: string
dry_run: dry_run:
description: Preview labels and comments without changing PRs description: Dry run
required: true required: true
default: true default: true
type: boolean type: boolean
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
- labeled
- unlabeled
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: read pull-requests: write
jobs: jobs:
check: check:
@@ -22,6 +36,7 @@ jobs:
- name: Checkout policy script - name: Checkout policy script
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.base.sha || github.ref }}
fetch-depth: 1 fetch-depth: 1
- name: Check contribution policy - name: Check contribution policy
+1 -2
View File
@@ -3,13 +3,12 @@
Yaak accepts community pull requests for: Yaak accepts community pull requests for:
- Bug fixes - Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes ## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated. If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
## Development Setup ## Development Setup
+2 -2
View File
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements. > Community PRs are currently limited to bug fixes.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback). > If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources ## Useful Resources
@@ -54,7 +54,7 @@ export function CommercialUseBanner({
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error); setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]); }, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || snoozed) { if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
return null; return null;
} }
@@ -96,10 +96,10 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
try { try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check"); const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status !== "active" && license.status !== "trialing"; return license.status === "personal_use";
} catch (err) { } catch (err) {
console.log("Failed to check license before commercial-use prompt", err); console.log("Failed to check license before commercial-use prompt", err);
return true; return false;
} }
} }
@@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -131,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
@@ -83,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = extractPathPlaceholders(activeRequest.url);
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -282,6 +282,22 @@ function EditorInner({
[disableTabIndent], [disableTabIndent],
); );
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
@@ -394,9 +410,9 @@ function EditorInner({
keymapCompartment.current.of( keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
), ),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({ ...getExtensions({
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
stateKey, stateKey,
@@ -553,7 +569,6 @@ function EditorInner({
function getExtensions({ function getExtensions({
stateKey, stateKey,
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
onChange, onChange,
@@ -562,7 +577,7 @@ function getExtensions({
onFocus, onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & { }: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"]; stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>; onChange: RefObject<EditorProps["onChange"]>;
@@ -612,7 +627,6 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []), ...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ // // ------------------------ //
// Things that must be last // // Things that must be last //
@@ -53,19 +53,17 @@ function pathParameters(
if (node.name === "Text") { if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders // Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) { for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i); const innerTree = tree.resolveInner(i);
if (innerTree.node.name === "url") { if (innerTree.node.name === "url") {
innerTree.toTree().iterate({ innerTree.node.cursor().iterate((node) => {
enter(node) { if (node.name !== "Placeholder") return;
if (node.name !== "Placeholder") return; const globalFrom = node.from;
const globalFrom = innerTree.node.from + node.from; const globalTo = node.to;
const globalTo = innerTree.node.from + node.to; const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const rawText = view.state.doc.sliceString(globalFrom, globalTo); const onClick = () => onClickPathParameter(rawText);
const onClick = () => onClickPathParameter(rawText); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); const deco = Decoration.replace({ widget, inclusive: false });
const deco = Decoration.replace({ widget, inclusive: false }); widgets.push(deco.range(globalFrom, globalTo));
widgets.push(deco.range(globalFrom, globalTo));
},
}); });
break; break;
} }
@@ -1,6 +1,13 @@
@top url { Protocol? Host Path? Query? } // Host is optional so URLs starting with `/` go straight to Path. Without this,
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }
Path { ("/" (Placeholder | PathSegment))+ } Path { ("/" PathSegment)+ }
Placeholder { ":" pathChars }
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
Query { "?" queryPair ("&" queryPair)* } Query { "?" queryPair ("&" queryPair)* }
@@ -9,9 +16,7 @@ Query { "?" queryPair ("&" queryPair)* }
Host { $[a-zA-Z0-9-_.:\[\]]+ } Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host } @precedence { Protocol, Host }
Placeholder { ":" ![/?#]+ } pathChars { ![/?#:]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
} }
@@ -1,9 +1,9 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
export const url = 1, export const
url = 1,
Protocol = 2, Protocol = 2,
Host = 3, Host = 3,
Port = 4, Path = 4,
Path = 5, PathSegment = 5,
Placeholder = 6, Placeholder = 6,
PathSegment = 7, Query = 7
Query = 8;
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";
function expectValidParse(input: string) {
expect(parser.parse(input).toString()).not.toContain("⚠");
}
function placeholderValues(input: string): string[] {
const values: string[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
});
return values;
}
describe("URL grammar Placeholder", () => {
test("recognizes path placeholders", () => {
expectValidParse("https://x.com/users/:id");
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
});
test("treats a colon suffix as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
});
test("treats repeated colon suffixes as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
});
test("does not recognize a colon in the middle of a plain path segment", () => {
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
});
test("does not recognize query parameters as path placeholders", () => {
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
});
test("recognizes placeholders in a path fragment after a templated base URL", () => {
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
// as in `${[ URL ]}/x/:foo/:hello`.
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
":foo",
":hello",
]);
});
});
@@ -1,20 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from "@lezer/lr"; import {LRParser} from "@lezer/lr"
import { highlight } from "./highlight"; import {highlight} from "./highlight"
export const parser = LRParser.deserialize({ export const parser = LRParser.deserialize({
version: 14, version: 14,
states: states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c", stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~", goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[", nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 17,
maxTerm: 14,
propSources: [highlight], propSources: [highlight],
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 2, repeatNodeCount: 3,
tokenData: tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
tokenizers: [0, 1, 2], tokenizers: [0, 1, 2],
topRules: { url: [0, 1] }, topRules: {"url":[0,1]},
tokenPrec: 63, tokenPrec: 99
}); })
@@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller"; import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button"; import { Button } from "./Button";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
interface EventViewerProps<T> { interface EventViewerProps<T> {
@@ -151,7 +153,7 @@ export function EventViewer<T>({
layout="vertical" layout="vertical"
storageKey={splitLayoutStorageKey} storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio} defaultRatio={defaultRatio}
minHeightPx={10} minHeightPx={72}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
@@ -202,23 +204,38 @@ export function EventViewer<T>({
); );
} }
export interface EventDetailAction { export type EventDetailAction =
/** Unique key for React */ | {
key: string; type?: "button";
/** Button label */ /** Unique key for React */
label: string; key: string;
/** Optional icon */ /** Button label */
icon?: ReactNode; label: string;
/** Click handler */ /** Optional icon */
onClick: () => void; icon?: ReactNode;
} /** Click handler */
onClick: () => void;
}
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps { interface EventDetailHeaderProps {
title: string; title: string;
prefix?: ReactNode; prefix?: ReactNode;
timestamp?: string; timestamp?: string;
actions?: EventDetailAction[]; actions?: EventDetailAction[];
copyText?: string; copyText?: string | (() => Promise<string | null>);
onClose?: () => void; onClose?: () => void;
} }
@@ -239,40 +256,56 @@ export function EventDetailHeader({
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3> <h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack> </HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) =>
<Button action.type === "select" ? (
key={action.key} <div key={action.key} className="w-32">
type="button" <Select
variant="border" name={action.key}
size="xs" label={action.label}
onClick={action.onClick} hideLabel
> size="xs"
{action.icon} value={action.value}
{action.label} options={action.options}
</Button> onChange={action.onChange}
))} />
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && ( {copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" /> <CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)} )}
{formattedTime && ( {formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span> <span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)} )}
<div {onClose != null && (
className={classNames( <div
copyText != null || className={classNames(
formattedTime || copyText != null ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"), formattedTime ||
)} ((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
> )}
<IconButton >
color="custom" <IconButton
className="text-text-subtle -mr-3" color="custom"
size="xs" className="text-text-subtle -mr-3"
icon="x" size="xs"
title="Close event panel" icon="x"
onClick={onClose} title="Close event panel"
/> onClick={onClose}
</div> />
</div>
)}
</HStack> </HStack>
</div> </div>
); );
@@ -1,21 +1,38 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource"; import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType"; import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer"; import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow"; import { EventViewerRow } from "../core/EventViewerRow";
import { IconButton } from "../core/IconButton";
import { IconTooltip } from "../core/IconTooltip";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
} }
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) { export function EventStreamViewer({ response }: Props) {
return ( return (
<Fragment <Fragment
@@ -29,64 +46,316 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) { function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false); const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false); const [showingLarge, setShowingLarge] = useState<boolean>(false);
const filterEventPreviewsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_filter_event_previews", response.requestId],
fallback: false,
});
const applyToDetailsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_apply_to_details", response.requestId],
fallback: false,
});
const renderMarkdownSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_render_markdown", response.requestId],
fallback: false,
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response); const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
showExtractedText &&
summary.data != null &&
summary.data.fragmentCount === 0 &&
!summary.isFetching &&
summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
const settingsItems = useMemo<DropdownItem[]>(
() => [
{
label: "Apply to Previews",
keepOpenOnSelect: true,
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
leftSlot: (
<Icon
icon={
filterEventPreviewsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
{
label: "Apply to Details",
keepOpenOnSelect: true,
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
leftSlot: (
<Icon
icon={
applyToDetailsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
return ( return (
<EventViewer <div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
events={events.data ?? []} <HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
getEventKey={(_, index) => String(index)} <div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
error={events.error ? String(events.error) : null} <Select
splitLayoutStorageKey="sse_events" name={`sse-summary-result-key-path-enabled::${response.requestId}`}
defaultRatio={0.4} label="Extracted text"
renderRow={({ event, index, isActive, onClick }) => ( hideLabel
<EventViewerRow size="xs"
isActive={isActive} value={summarySettings.enabled ? "jsonpath" : "off"}
onClick={onClick} options={[
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />} { label: "Full events", value: "off" },
content={ { label: "JSONPath", value: "jsonpath" },
<HStack space={2} className="items-center"> ]}
<EventLabels event={event} index={index} isActive={isActive} /> onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> />
</HStack> </div>
} {summarySettings.enabled && (
/> <>
)} <div className="min-w-40 flex-1">
renderDetail={({ event, index, onClose }) => ( <Input
<EventDetail label="Result JSON path"
event={event} hideLabel
index={index} size="xs"
showLarge={showLarge} autocomplete={sseSummaryResultKeyPathAutocomplete}
showingLarge={showingLarge} defaultValue={summarySettings.resultKeyPathInputValue}
setShowLarge={setShowLarge} forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
setShowingLarge={setShowingLarge} placeholder="$.choices[0].delta.content"
onClose={onClose} rightSlot={
/> showResultKeyPathWarning ? (
)} <div className="flex items-center px-2">
/> <IconTooltip
tabIndex={-1}
icon="alert_triangle"
iconColor="notice"
content="No text fragments matched this JSONPath."
/>
</div>
) : null
}
stateKey={`sse-summary-result-key-path::${response.requestId}`}
tint={showResultKeyPathWarning ? "notice" : undefined}
onChange={summarySettings.setResultKeyPath}
/>
</div>
<Dropdown items={settingsItems}>
<IconButton
size="xs"
variant="border"
icon="settings"
title="Extracted text settings"
/>
</Dropdown>
</>
)}
</HStack>
<SplitLayout
layout="vertical"
storageKey={`sse_extracted_text::${response.requestId}`}
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
minHeightPx={72}
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</div>
)}
secondSlot={
showExtractedText
? ({ style }) => (
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
)
: null
}
/>
</div>
); );
} }
function SseSummaryFooter({
error,
fragmentCount,
isLoading,
onRenderMarkdownChange,
renderMarkdown,
resultKeyPath,
style,
summary,
}: {
error: string | null;
fragmentCount: number;
isLoading: boolean;
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
renderMarkdown: boolean;
resultKeyPath: string;
style: CSSProperties;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const actions = useMemo(
() => [
{
key: "sse-summary-format",
label: "Extracted text format",
type: "select" as const,
value: renderMarkdown ? "markdown" : "text",
options: [
{ label: "Text", value: "text" },
{ label: "Markdown", value: "markdown" },
],
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
},
],
[onRenderMarkdownChange, renderMarkdown],
);
return (
<div
style={style}
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
>
<div className="pt-2">
<EventDetailHeader
actions={actions}
title="Extracted Text"
copyText={hasSummary ? summary : undefined}
/>
</div>
<div
className={classNames(
"min-h-0 py-2 overflow-auto",
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
)}
>
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading extracted text...</span>
) : hasSummary ? (
renderMarkdown ? (
<div className="min-h-0">
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
</div>
) : (
<pre className="font-mono whitespace-pre-wrap break-words select-auto cursor-auto">
{summary}
</pre>
)
) : (
<EmptyStateText className="gap-1.5">
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</EmptyStateText>
)}
</div>
</div>
);
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
filterEventPreview: boolean,
): string {
if (filterEventPreview && resultKeyPath != null) {
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
}
return event.data.slice(0, 1000);
}
function EventDetail({ function EventDetail({
applyJsonPath,
event, event,
index, index,
resultKeyPath,
showLarge, showLarge,
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose, onClose,
}: { }: {
applyJsonPath: boolean;
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
resultKeyPath: string | null;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const detailText = useMemo(
() =>
applyJsonPath && resultKeyPath != null
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
: event.data,
[applyJsonPath, event.data, resultKeyPath],
);
const language = useMemo<"text" | "json">(() => { const language = useMemo<"text" | "json">(() => {
if (!event?.data) return "text"; if (!detailText) return "text";
return isJSON(event?.data) ? "json" : "text"; return isJSON(detailText) ? "json" : "text";
}, [event?.data]); }, [detailText]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -95,7 +364,7 @@ function EventDetail({
prefix={<EventLabels event={event} index={index} />} prefix={<EventLabels event={event} index={index} />}
onClose={onClose} onClose={onClose}
/> />
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && detailText.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
<div> <div>
@@ -117,7 +386,7 @@ function EventDetail({
</div> </div>
</VStack> </VStack>
) : ( ) : (
<FormattedEditor language={language} text={event.data} /> <FormattedEditor language={language} text={detailText} />
)} )}
</div> </div>
); );
@@ -142,14 +411,17 @@ function EventLabels({
}) { }) {
return ( return (
<HStack space={1.5} alignItems="center" className={className}> <HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}> <EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.id ?? index} {event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
{event.eventType}
</InlineCode>
)}
</HStack> </HStack>
); );
} }
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
<span className="relative">{children}</span>
</InlineCode>
);
}
@@ -6,7 +6,12 @@ import { getResponseBodyEventSource } from "../lib/responseBody";
export function useResponseBodyEventSource(response: HttpResponse) { export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({ return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["response-body-event-source", response.id, response.contentLength], queryKey: [
"response-body-event-source",
response.id,
response.updatedAt,
response.contentLength,
],
queryFn: () => getResponseBodyEventSource(response), queryFn: () => getResponseBodyEventSource(response),
}); });
} }
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { SseSummary } from "@yaakapp-internal/sse";
import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
});
}
@@ -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([]);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Extract `:name`-style path placeholders from a URL string.
*
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
* placeholder `:id` and `:increment-importance` is literal text.
*
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
* has no placeholders. Returned names include the leading colon.
*/
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
+32 -4
View File
@@ -1,7 +1,8 @@
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import type { FilterResponse } from "@yaakapp-internal/plugins"; import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent } from "@yaakapp-internal/sse"; import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { invokeCmd } from "./tauri"; import { invokeCmd } from "./tauri";
export async function getResponseBodyText({ export async function getResponseBodyText({
@@ -27,9 +28,36 @@ export async function getResponseBodyEventSource(
response: HttpResponse, response: HttpResponse,
): Promise<ServerSentEvent[]> { ): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return []; if (!response.bodyPath) return [];
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", { try {
filePath: response.bodyPath, const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
}); filePath: response.bodyPath,
});
if (events.length > 0) {
return events;
}
} catch {
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
id: String(index),
retry: null,
}));
}
export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
} }
export async function getResponseBodyBytes( export async function getResponseBodyBytes(
+16 -1
View File
@@ -34,7 +34,10 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
return url.to_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 let result = re
.replace_all(url, |cap: &regex::Captures| { .replace_all(url, |cap: &regex::Captures| {
format!( 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] #[test]
fn placeholder_missing() { fn placeholder_missing() {
let p = HttpUrlParameter { let p = HttpUrlParameter {
+1
View File
@@ -1 +1,2 @@
export * from "./bindings/sse"; export * from "./bindings/sse";
export * from "./summary";
+7 -1
View File
@@ -2,5 +2,11 @@
"name": "@yaakapp-internal/sse", "name": "@yaakapp-internal/sse",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "index.ts" "dependencies": {
"jsonpath-plus": "^10.3.0"
},
"main": "index.ts",
"scripts": {
"test": "vitest run"
}
} }
+51
View File
@@ -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",
});
});
});
+131
View File
@@ -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;
}
}
+4 -1
View File
@@ -356,7 +356,10 @@
}, },
"crates/yaak-sse": { "crates/yaak-sse": {
"name": "@yaakapp-internal/sse", "name": "@yaakapp-internal/sse",
"version": "1.0.0" "version": "1.0.0",
"dependencies": {
"jsonpath-plus": "^10.3.0"
}
}, },
"crates/yaak-sync": { "crates/yaak-sync": {
"name": "@yaakapp-internal/sync", "name": "@yaakapp-internal/sync",
+38 -12
View File
@@ -27,7 +27,7 @@ interface Props {
resizeHandleClassName?: string; resizeHandleClassName?: string;
} }
const baseProperties = { minWidth: 0 }; const baseProperties = { minHeight: 0, minWidth: 0 };
const areaL = { ...baseProperties, gridArea: "left" }; const areaL = { ...baseProperties, gridArea: "left" };
const areaR = { ...baseProperties, gridArea: "right" }; const areaR = { ...baseProperties, gridArea: "right" };
const areaD = { ...baseProperties, gridArea: "drag" }; const areaD = { ...baseProperties, gridArea: "drag" };
@@ -60,23 +60,25 @@ export function SplitLayout({
const size = useContainerSize(containerRef); const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH; const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize); 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>(() => { const styles = useMemo<CSSProperties>(() => {
return { return {
...style, ...style,
gridTemplate: vertical gridTemplate: vertical
? ` ? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr) ' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
' ${areaD.gridArea}' 0 ' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr) ' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
/ 1fr / 1fr
` `
: ` : `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,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(() => { const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio); if (vertical) setHeight(defaultRatio);
@@ -96,22 +98,36 @@ export function SplitLayout({
const containerHeight = const containerHeight =
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom); $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
return;
}
const mouseStartX = e.xStart; const mouseStartX = e.xStart;
const mouseStartY = e.yStart; const mouseStartY = e.yStart;
const startWidth = containerWidth * width; const startWidth = containerWidth * renderedWidth;
const startHeight = containerHeight * height; const startHeight = containerHeight * renderedHeight;
if (vertical) { if (vertical) {
const maxHeightPx = containerHeight - minHeightPx; const minHeight = Math.min(minHeightPx, containerHeight);
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx); const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
setHeight(newHeightPx / containerHeight); setHeight(newHeightPx / containerHeight);
} else { } else {
const maxWidthPx = containerWidth - minWidthPx; const minWidth = Math.min(minWidthPx, containerWidth);
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx); const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
setWidth(newWidthPx / containerWidth); setWidth(newWidthPx / containerWidth);
} }
}, },
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth], [
renderedWidth,
renderedHeight,
vertical,
minHeightPx,
setHeight,
minWidthPx,
setWidth,
],
); );
return ( return (
@@ -140,3 +156,13 @@ export function SplitLayout({
</div> </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);
}
+6 -2
View File
@@ -180,8 +180,12 @@ function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters">
v += `:${url.port}`; v += `:${url.port}`;
} }
if ("path" in url && Array.isArray(url.path) && url.path.length > 0) { if ("path" in url) {
v += `/${Array.isArray(url.path) ? url.path.join("/") : url.path}`; 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[] = []; 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",
}),
]);
});
}); });