Compare commits

..

33 Commits

Author SHA1 Message Date
Gregory Schier 1dd7e728ff Add contribution policy workflow 2026-06-30 13:34:11 -07:00
Gregory Schier 3a349bccfe Fix wording 2026-06-30 10:28:39 -07:00
Gregory Schier 13a667a9b1 Add commercial use nudge banners (#478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Nguyễn Huỳnh Anh Khoa <113995598+anhkhoakz@users.noreply.github.com>
Co-authored-by: startsevdenis <mail@startsevds.ru>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:23:13 -07:00
Gregory Schier 420c6e2c4a Tweak message size placeholder and notifications 2026-06-30 09:44:11 -07:00
Gregory Schier bbdfbcb9ca Add configurable gRPC and WebSocket message size limit (#487) 2026-06-30 09:14:41 -07:00
dependabot[bot] d1e6f8fb33 Bump js-cookie and react-use (#488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:33:14 -07:00
dependabot[bot] 930a816f42 Bump ws from 8.20.1 to 8.21.0 (#480)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:54 -07:00
dependabot[bot] ec0143aa93 Bump hono from 4.12.18 to 4.12.25 (#479)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:47 -07:00
dependabot[bot] 3cc54dea22 Bump vite-plus from 0.1.20 to 0.1.24 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:41 -07:00
dependabot[bot] a8fb144c09 Bump esbuild and tsx (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:33 -07:00
dependabot[bot] 6813fa8bf2 Bump shell-quote from 1.8.3 to 1.8.4 (#471)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:25 -07:00
dependabot[bot] cf7de26a2e Bump tar from 0.4.45 to 0.4.46 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:16 -07:00
dependabot[bot] 8676272657 Bump qs from 6.14.1 to 6.15.2 (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:10 -07:00
startsevdenis c3aecfdc0c fix: increase tonic gRPC max_decoding_message_size to 64MB 2026-06-29 16:06:02 -07:00
Gregory Schier 09adcda2d9 Add plugin metadata generation (#485) 2026-06-29 12:31:49 -07:00
Gregory Schier 18b983bfe5 Add CLI import and export commands (#484) 2026-06-29 11:43:20 -07:00
Gregory Schier 9ffd8d4810 Flush model writes before sending HTTP requests 2026-06-29 10:25:15 -07:00
Gregory Schier 55d0066efd Fix spell correction prompt showing (#483) 2026-06-29 08:54:01 -07:00
Nguyễn Huỳnh Anh Khoa 1de0a5942c fix(manager): remove stale plugins with missing directories (#481) 2026-06-26 22:33:06 -07:00
Gregory Schier fd0ca6d455 Fix bulk env var parsing (#482) 2026-06-26 21:58:38 -07:00
Gregory Schier 84b89e2708 update theme generation logic 2026-06-21 10:37:43 -07:00
Gregory Schier 7db3e9b879 Fix filter field value highlighting 2026-06-20 00:31:42 -07:00
Gregory Schier 8109a28967 Improve sidebar filter suggestions (#477) 2026-06-20 00:10:05 -07:00
Gregory Schier 3de9a1edd4 Persist response filter per request 2026-06-11 09:09:12 -07:00
Gregory Schier 1b28dfd9d1 Actually fix overflowing text when Input has right slot items 2026-06-03 12:44:33 -07:00
Saverio Cannone 9f51c61447 Fix: long model names overflowing in delete dialog (#468) 2026-05-26 23:16:50 -07:00
zPush b17ccbeebe Fix: Secret input field texts were bleeding under obscure toggle button (#461)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-05-21 09:36:20 -07:00
Jeroen van der Merwe 463cc6f5a3 feat: Extract authentication when using the cURL importer (#423) 2026-05-21 09:00:22 -07:00
dependabot[bot] 1307ea4e67 Bump ws from 8.19.0 to 8.20.1 (#464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 07:58:42 -07:00
dependabot[bot] 710b8e34ac Bump postcss from 8.5.6 to 8.5.14 (#449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 07:26:54 -07:00
Stijn Brouwers f251772a4a feat(cookies): Allow manually adding cookies to the cookiejar (#457)
Co-authored-by: Stijn BROUWERS <stijn.brouwers@ext.ec.europa.eu>
2026-05-20 07:43:03 -07:00
Gregory Schier fa40ceaa31 Add cookie editing and inherited request settings (#463) 2026-05-18 08:59:49 -07:00
Gregory Schier dcfdf077e7 Add staged pre-commit checks 2026-05-13 14:22:43 -07:00
147 changed files with 10021 additions and 2390 deletions
@@ -0,0 +1,566 @@
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 LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const LABELS = {
accepted: {
name: "contribution: accepted",
color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.",
},
approvedFeedback: {
name: "contribution: approved feedback",
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",
description: "Community PR needs an approved feedback item before review.",
},
largeDiff: {
name: "contribution: large diff",
color: "FBCA04",
description:
"Community PR has a larger-than-usual diff for a small-scope contribution.",
},
};
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name);
const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.",
approvedFeedback:
"If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.",
readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests 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 checkboxState(body, label) {
const flexibleLabel = escapeRegExp(label).replace(/\\ /g, "\\s+");
const pattern = new RegExp(
`^\\s*[-*]\\s*\\[([ xX])\\]\\s*${flexibleLabel}\\s*$`,
"im",
);
const match = body.match(pattern);
if (match == null) {
return null;
}
return match[1].toLowerCase() === "x";
}
function findFeedbackUrl(body) {
return (
body.match(
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
)?.[0] ?? null
);
}
function analyzePullRequest(pr) {
const body = normalizeBody(pr.body);
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 (!templateUsed) {
blockers.push({
label: LABELS.needsTemplate.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 smallScope = states.smallScope === true;
const approvedFeedback = states.approvedFeedback === true;
if (!hasSummary) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Add a short summary describing the bug fix or improvement.",
});
}
if (smallScope && approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Choose either the small-scope checkbox or the approved-feedback checkbox, not both.",
});
} else if (!smallScope && !approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Check whether this is a bug fix or small-scope improvement, or confirm that an approved feedback item is linked.",
});
} else if (approvedFeedback && feedbackUrl == null) {
blockers.push({
label: LABELS.needsApproval.name,
message:
"Link the approved feedback item where contribution approval was explicitly stated.",
});
}
if (states.readContributing !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
});
}
if (states.testedLocally !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that the change was tested locally.",
});
}
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that tests were added or updated when reasonable.",
});
}
}
const desiredLabels = new Set(blockers.map((blocker) => blocker.label));
if (blockers.length === 0) {
desiredLabels.add(
states.approvedFeedback
? LABELS.approvedFeedback.name
: LABELS.accepted.name,
);
}
if (largeDiff) {
desiredLabels.add(LABELS.largeDiff.name);
}
return {
blockers,
changedFiles,
desiredLabels: [...desiredLabels],
largeDiff,
templateUsed,
totalChangedLines,
};
}
function buildBlockingComment(analysis) {
const lines = [
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.",
"",
"This PR cannot be accepted yet. Please update the PR description to address:",
"",
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
];
if (analysis.largeDiff) {
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.`,
);
}
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.",
);
return lines.join("\n");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
if (skipped) {
return `#${pr.number} ${pr.title} - skipped (${skipReason})`;
}
const status =
analysis.blockers.length > 0
? `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}`;
}
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 checkPullRequest({
github,
core,
owner,
repo,
pullNumber,
dryRun,
}) {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const pr = response.data;
const issueNumber = pr.number;
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] ${summary}`);
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: buildBlockingComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await deletePolicyComment({ github, owner, repo, issueNumber });
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,
});
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRun =
context.eventName === "workflow_dispatch" &&
context.payload.inputs?.dry_run !== "false";
const pullRequests =
payloadPr == null
? await listOpenPullRequests({ github, owner, repo })
: [payloadPr];
const results = [];
if (dryRun) {
core.notice("Running contribution policy in dry-run mode.");
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Result", header: true },
],
...results.map((result) => [`#${result.number}`, result.summary]),
])
.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,
};
+32
View File
@@ -0,0 +1,32 @@
name: Contribution Policy
on:
workflow_dispatch:
inputs:
dry_run:
description: Preview labels and comments without changing PRs
required: true
default: true
type: boolean
permissions:
contents: read
issues: write
pull-requests: read
jobs:
check:
name: Check contribution policy
runs-on: ubuntu-latest
steps:
- name: Checkout policy script
uses: actions/checkout@v4
with:
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
View File
@@ -1 +1,2 @@
vp lint vp lint
vp staged
Generated
+11 -9
View File
@@ -215,7 +215,7 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.52.0",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static 1.5.0", "lazy_static 1.5.0",
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.45" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"once_cell", "once_cell",
"rustix 1.0.7", "rustix 1.0.7",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -10052,6 +10052,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
@@ -10182,6 +10183,7 @@ dependencies = [
"webbrowser", "webbrowser",
"yaak", "yaak",
"yaak-api", "yaak-api",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
@@ -1,19 +1,10 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog"; import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog"; import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) { export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return; if (workspaceId == null) return;
showDialog({ WorkspaceSettingsDialog.show(workspaceId, tab);
id: "workspace-settings",
size: "md",
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
} }
@@ -4,6 +4,7 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
@@ -89,6 +90,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner> </Banner>
)} )}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput <PlainInput
required required
label="Repository URL" label="Repository URL"
@@ -15,6 +15,7 @@ import {
import { createFolder } from "../commands/commands"; import { createFolder } from "../commands/commands";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { openSettings } from "../commands/openSettings"; import { openSettings } from "../commands/openSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { switchWorkspace } from "../commands/switchWorkspace"; import { switchWorkspace } from "../commands/switchWorkspace";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar"; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment"; import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
import { copyToClipboard } from "../lib/copy"; import { copyToClipboard } from "../lib/copy";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { editEnvironment } from "../lib/editEnvironment"; import { editEnvironment } from "../lib/editEnvironment";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt"; import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { import {
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
action: "settings.show", action: "settings.show",
onSelect: () => openSettings.mutate(null), onSelect: () => openSettings.mutate(null),
}, },
{
key: "workspace_settings.open",
label: "Open Workspace Settings",
action: "workspace_settings.show",
onSelect: () => openWorkspaceSettings(),
},
{ {
key: "app.create", key: "app.create",
label: "Create Workspace", label: "Create Workspace",
@@ -127,13 +133,9 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{ {
key: "cookies.show", key: "cookies.show",
label: "Show Cookies", label: "Show Cookies",
action: "cookies_editor.show",
onSelect: async () => { onSelect: async () => {
showDialog({ CookieDialog.show(activeCookieJar?.id ?? null);
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
}, },
}, },
{ {
@@ -0,0 +1,130 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre using Yaak at work, please purchase a license.";
export function CommercialUseBanner({
source,
title,
}: {
source: string;
title: string;
}) {
const [visible, setVisible] = useState(false);
const snoozeStartedRef = useRef(false);
const {
isLoading: isSnoozeLoading,
set: setSnoozedAt,
value: snoozedAt,
} = useKeyValue<string | null>({
namespace: "global",
key: "commercial-use-banner-snoozed-at",
fallback: null,
});
useEffect(() => {
let canceled = false;
shouldShowCommercialUsePrompt()
.then((shouldShow) => {
if (!canceled) setVisible(shouldShow);
})
.catch(console.error);
return () => {
canceled = true;
};
}, [source]);
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
const handleShow = useCallback(() => {
if (snoozeStartedRef.current || snoozed) {
return;
}
snoozeStartedRef.current = true;
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || snoozed) {
return null;
}
return (
<div className="w-full">
<DismissibleBanner
id={`commercial-use:${source}`}
color="info"
className="w-full"
onDismiss={() =>
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
}
onShow={handleShow}
actions={[
{
label: "Purchase License",
color: "info",
variant: "solid",
onClick: () => {
openCommercialUsePricing(source).catch(console.error);
},
},
]}
>
<div className="text-sm">
<p className="font-semibold text-text">{title}</p>
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
</div>
</DismissibleBanner>
</div>
);
}
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
if (appInfo.featureLicense !== true) {
return false;
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status !== "active" && license.status !== "trialing";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return true;
}
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
if (value == null) return false;
try {
const snooze = JSON.parse(value) as { at?: unknown };
const at = typeof snooze.at === "string" ? snooze.at : null;
return isWithinMs(at, ms);
} catch {
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
return isWithinMs(value, ms);
}
}
function isWithinMs(date: string | null, ms: number): boolean {
if (date == null) return false;
const time = new Date(date).getTime();
if (Number.isNaN(time)) return false;
return Date.now() - time < ms;
}
+699 -35
View File
@@ -1,9 +1,40 @@
import type { Cookie } from "@yaakapp-internal/models"; import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models"; import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import {
type ComponentProps,
type CSSProperties,
type FormEvent,
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
} from "react";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { cookieDomain } from "../lib/model_util"; import { cookieDomain } from "../lib/model_util";
import { Banner, InlineCode } from "@yaakapp-internal/ui"; import {
Icon,
SplitLayout,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { showAlert } from "../lib/alert";
interface Props { interface Props {
cookieJarId: string | null; cookieJarId: string | null;
@@ -12,56 +43,689 @@ interface Props {
export const CookieDialog = ({ cookieJarId }: Props) => { export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom); const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId); const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
const [filter, setFilter] = useState("");
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
const [draftExpiresInput, setDraftExpiresInput] = useState("");
const editorFormRef = useRef<HTMLFormElement>(null);
const filteredCookies = useMemo(() => {
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
}, [cookieJar?.cookies, filter]);
const selectedCookie = useMemo(
() =>
selectedCookieKey == null
? null
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
[filteredCookies, selectedCookieKey],
);
const detailCookie = draftCookie ?? selectedCookie;
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
const isEditingCookie = draftCookie != null;
const handleAddCookie = () => {
setSelectedCookieKey(null);
setEditingCookieKey(NEW_COOKIE_KEY);
setDraftCookie(newCookieDraft());
setDraftExpiresInput("");
};
const handleEditCookie = () => {
if (selectedCookie == null) {
return;
}
setEditingCookieKey(cookieKey(selectedCookie));
setDraftCookie(selectedCookie);
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
};
const handleCancelEdit = () => {
if (isCreatingCookie) {
setSelectedCookieKey(null);
}
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
const handleCloseDetails = () => {
if (isEditingCookie) {
handleCancelEdit();
return;
}
setSelectedCookieKey(null);
};
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.expires !== "SessionEnd") {
const expires = cookieExpiresFromInput(draftExpiresInput);
if (expires == null) {
showAlert({
id: "invalid-cookie-expires",
title: "Invalid Cookie",
body: "Cookie expiration must be a valid date.",
});
return;
}
nextCookie = { ...nextCookie, expires };
}
const nextCookieKey = cookieKey(nextCookie);
const nextCookies = cookieJar.cookies.filter((cookie) => {
const key = cookieKey(cookie);
if (editingCookieKey != null && key === editingCookieKey) {
return false;
}
return key !== nextCookieKey;
});
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
if (cookieJar == null) { if (cookieJar == null) {
return <div>No cookie jar selected</div>; return <div>No cookie jar selected</div>;
} }
if (cookieJar.cookies.length === 0) {
return ( return (
<Banner> <div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
</Banner> <PlainInput
); name="cookie-filter"
label="Filter cookies"
hideLabel
placeholder="Filter cookies"
defaultValue={filter}
forceUpdateKey={filterUpdateKey}
onChange={setFilter}
rightSlot={
filter.length > 0 && (
<IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
setFilter("");
setFilterUpdateKey((key) => key + 1);
}}
/>
)
} }
/>
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
</div>
{cookieJar.cookies.length === 0 && detailCookie == null ? (
<EmptyStateText>
Cookies will appear when a response includes a Set-Cookie header.
</EmptyStateText>
) : filteredCookies.length === 0 && detailCookie == null ? (
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
) : (
<SplitLayout
layout="vertical"
storageKey="cookie-dialog-details"
defaultRatio={0.5}
className="-mx-2"
minHeightPx={10}
firstSlot={({ style }) =>
filteredCookies.length === 0 ? (
<div style={style}>
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
</div>
) : (
<Table scrollable style={style} className="pr-0.5">
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
<TableHeaderCell>Expires</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>HTTP Only</TableHeaderCell>
<TableHeaderCell>Secure</TableHeaderCell>
<TableHeaderCell>Same Site</TableHeaderCell>
<TableHeaderCell>
<IconButton
icon="list_x"
size="sm"
className="text-text-subtle"
title="Clear all cookies"
onClick={() => {
setSelectedCookieKey(null);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
void patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
{filteredCookies.map((c: Cookie) => {
const key = cookieKey(c);
const isSelected = key === selectedCookieKey;
return ( return (
<div className="pb-2"> <TableRow
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> key={key}
<thead> className={classNames(
<tr> "group/tr cursor-default",
<th className="py-2 text-left">Domain</th> isSelected && "[&_td]:bg-surface-highlight",
<th className="py-2 text-left pl-4">Cookie</th> !isSelected && "hover:[&_td]:bg-surface-hover",
<th className="py-2 pl-4" /> )}
</tr> onClick={() => {
</thead> setSelectedCookieKey(key);
<tbody className="divide-y divide-surface-highlight"> setEditingCookieKey(null);
{cookieJar?.cookies.map((c: Cookie) => ( setDraftCookie(null);
<tr key={JSON.stringify(c)}> setDraftExpiresInput("");
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0"> }}
{cookieDomain(c)} >
</td> <TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars"> {c.name}
{c.raw_cookie} </TableCell>
</td> <TruncatedWideTableCell className="min-w-[10rem]">
<td className="max-w-0 w-10"> {c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
<TableCell>{c.path}</TableCell>
<TableCell>{cookieExpires(c)}</TableCell>
<TableCell>{cookieSize(c)}</TableCell>
<TableCell>
<Icon
icon={c.httpOnly ? "check" : "x"}
className={classNames(!c.httpOnly && "opacity-10")}
/>
</TableCell>
<TableCell>
<Icon
icon={c.secure ? "check" : "x"}
className={classNames(!c.secure && "opacity-10")}
/>
</TableCell>
<TableCell>{c.sameSite}</TableCell>
<TableCell className="rounded-r pr-2">
<IconButton <IconButton
icon="trash" icon="trash"
size="xs" size="xs"
iconSize="sm" iconSize="sm"
title="Delete" title="Delete"
className="ml-auto" className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
onClick={() => onClick={(event) => {
patchModel(cookieJar, { event.stopPropagation();
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c), if (isSelected) {
}) setSelectedCookieKey(null);
}
if (editingCookieKey === key) {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}
void patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)
}
secondSlot={
detailCookie == null
? null
: ({ style }) => (
<CookieDetailsPane
formRef={editorFormRef}
isEditing={isEditingCookie}
onSubmit={handleSaveCookie}
style={style}
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: () => editorFormRef.current?.requestSubmit(),
},
{
key: "cancel",
label: "Cancel",
onClick: handleCancelEdit,
},
]
: [
{
key: "edit",
label: "Edit",
onClick: handleEditCookie,
},
]
}
onClose={handleCloseDetails}
/>
{isEditingCookie ? (
<CookieEditor
cookie={detailCookie}
expiresInputValue={draftExpiresInput}
onChange={setDraftCookie}
onExpiresInputChange={setDraftExpiresInput}
/>
) : (
<CookieDetails cookie={detailCookie} />
)}
</CookieDetailsPane>
)
} }
/> />
</td> )}
</tr>
))}
</tbody>
</table>
</div> </div>
); );
}; };
function CookieDetailsPane({
children,
formRef,
isEditing,
onSubmit,
style,
}: {
children: ReactNode;
formRef: RefObject<HTMLFormElement | null>;
isEditing: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
style: CSSProperties;
}) {
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
if (isEditing) {
return (
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
{children}
</form>
);
}
return (
<div style={style} className={className}>
{children}
</div>
);
}
CookieDialog.show = (cookieJarId: string | null) => {
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
if (cookieJar == null) {
showAlert({
id: "invalid-jar",
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
title: "Invalid Cookie Jar",
});
return;
}
showDialog({
id: "cookies",
title: `${cookieJar.name} Cookies`,
size: "full",
render: () => <CookieDialog cookieJarId={cookieJarId} />,
});
};
function CookieDetails({ cookie }: { cookie: Cookie }) {
return (
<div className="overflow-y-auto">
<KeyValueRows selectable>
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
</CookieKeyValueRow>
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
{cookie.sameSite && (
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
)}
</KeyValueRows>
</div>
);
}
function CookieEditor({
cookie,
expiresInputValue,
onChange,
onExpiresInputChange,
}: {
cookie: Cookie;
expiresInputValue: string;
onChange: (cookie: Cookie) => void;
onExpiresInputChange: (value: string) => void;
}) {
const sessionCookie = cookie.expires === "SessionEnd";
return (
<div className="overflow-y-auto">
<KeyValueRows>
<CookieKeyValueRow align="middle" label="Name">
<CookieTextInput
required
autoFocus
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookie.name}
onChange={(name) => onChange({ ...cookie, name })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Value">
<CookieTextarea
value={cookie.value}
onChange={(value) => onChange({ ...cookie, value })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Domain">
<CookieTextInput
required
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookieDomainInputValue(cookie)}
placeholder="example.com"
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Path">
<CookieTextInput
value={cookie.path}
placeholder="/"
onChange={(path) => onChange({ ...cookie, path })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">
<div className="grid gap-1">
<Checkbox
checked={sessionCookie}
title="Session cookie"
onChange={(checked) => {
if (checked) {
onChange({ ...cookie, expires: "SessionEnd" });
return;
}
const expiresInput =
cookieExpiresFromInput(expiresInputValue) == null
? defaultCookieExpiresInputValue()
: expiresInputValue;
onExpiresInputChange(expiresInput);
onChange({
...cookie,
expires: cookieExpiresFromInput(expiresInput)!,
});
}}
/>
<CookieTextInput
value={sessionCookie ? "" : expiresInputValue}
disabled={sessionCookie}
onChange={(value) => {
onExpiresInputChange(value);
const expires = cookieExpiresFromInput(value);
if (expires != null) {
onChange({ ...cookie, expires });
}
}}
/>
</div>
</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="HTTP Only">
<Checkbox
hideLabel
title="HTTP Only"
checked={cookie.httpOnly}
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Secure">
<Checkbox
hideLabel
title="Secure"
checked={cookie.secure}
onChange={(secure) => onChange({ ...cookie, secure })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Same Site">
<Select
hideLabel
name="cookie-same-site"
label="Same Site"
value={cookie.sameSite ?? ""}
size="xs"
className="w-full"
options={[
{ label: "n/a", value: "" },
{ label: "Lax", value: "Lax" },
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/>
</CookieKeyValueRow>
</KeyValueRows>
</div>
);
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
}
function CookieTextInput({
autoFocus,
disabled,
onChange,
pattern,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
pattern?: string;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
autoCapitalize="off"
autoCorrect="off"
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
pattern={pattern}
placeholder={placeholder}
required={required}
type="text"
value={value}
/>
);
}
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
);
}
const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-none",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
function cookieSize(cookie: Cookie) {
const encoder = new TextEncoder();
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
}
function newCookieDraft(): Cookie {
return {
name: "",
value: "",
domain: "NotPresent",
expires: "SessionEnd",
path: "/",
secure: false,
httpOnly: false,
sameSite: null,
};
}
function normalizeCookie(cookie: Cookie): Cookie {
return {
...cookie,
domain: normalizeCookieDomain(cookie.domain),
name: cookie.name.trim(),
path: cookie.path.trim() || "/",
};
}
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
if (domain === "NotPresent" || domain === "Empty") {
return domain;
}
if ("Suffix" in domain) {
return { Suffix: domain.Suffix.trim() };
}
return { HostOnly: domain.HostOnly.trim() };
}
function cookieDomainInputValue(cookie: Cookie) {
const domain = cookieDomain(cookie);
return domain === "n/a" ? "" : domain;
}
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { ...cookie, domain: "NotPresent" };
}
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
return { ...cookie, domain: { Suffix: trimmedDomain } };
}
return { ...cookie, domain: { HostOnly: trimmedDomain } };
}
function cookieExpires(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "Session";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return cookie.expires.AtUtc;
}
const date = new Date(expiresSeconds * 1000);
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
}
function cookieExpiresInputValue(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return "";
}
return new Date(expiresSeconds * 1000).toISOString();
}
function defaultCookieExpiresInputValue() {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
return null;
}
return { AtUtc: `${Math.floor(time / 1000)}` };
}
function cookieMatchesFilter(cookie: Cookie, filter: string) {
const query = filter.trim().toLowerCase();
if (query.length === 0) {
return true;
}
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
value.toLowerCase().includes(query),
);
}
function cookieKey(cookie: Cookie) {
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
}
function cookieDomainKey(domain: Cookie["domain"]) {
if (typeof domain !== "string" && "HostOnly" in domain) {
return `HostOnly:${domain.HostOnly}`;
}
if (typeof domain !== "string" && "Suffix" in domain) {
return `Suffix:${domain.Suffix}`;
}
return domain;
}
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar"; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useCreateCookieJar } from "../hooks/useCreateCookieJar"; import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt"; import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams"; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from "./CookieDialog"; import { CookieDialog } from "./CookieDialog";
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
leftSlot: <Icon icon="cookie" />, leftSlot: <Icon icon="cookie" />,
onSelect: () => { onSelect: () => {
if (activeCookieJar == null) return; if (activeCookieJar == null) return;
showDialog({ CookieDialog.show(activeCookieJar.id);
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
}, },
}, },
{ {
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
wrapperClassName?: string;
} }
export function EmptyStateText({ children, className }: Props) { export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return ( return (
<div className="w-full h-full pb-2"> <div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div <div
className={classNames( className={classNames(
className, className,
@@ -8,6 +8,7 @@ import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -85,8 +86,10 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]"> <div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead> <thead>
<tr> <tr>
@@ -137,9 +140,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
@@ -21,6 +21,7 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
interface Props { interface Props {
folderId: string | null; folderId: string | null;
@@ -29,6 +30,7 @@ interface Props {
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_SETTINGS = "settings";
const TAB_VARIABLES = "variables"; const TAB_VARIABLES = "variables";
const TAB_GENERAL = "general"; const TAB_GENERAL = "general";
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
| typeof TAB_HEADERS | typeof TAB_HEADERS
| typeof TAB_GENERAL | typeof TAB_GENERAL
| typeof TAB_SETTINGS
| typeof TAB_VARIABLES; | typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
(e) => e.parentModel === "folder" && e.parentId === folderId, (e) => e.parentModel === "folder" && e.parentId === folderId,
); );
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length; const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
const tabs = useMemo<TabItem[]>(() => { const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return []; if (folder == null) return [];
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
value: TAB_GENERAL, value: TAB_GENERAL,
label: "General", label: "General",
}, },
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
...headersTab, ...headersTab,
...authTab, ...authTab,
{ {
@@ -68,7 +77,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null, rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
}, },
]; ];
}, [authTab, folder, headersTab, numVars]); }, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
if (folder == null) return null; if (folder == null) return null;
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`headers.${folder.id}`} stateKey={`headers.${folder.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<ModelSettingsEditor model={folder} />
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? ( {folderEnvironment == null ? (
<EmptyStateText> <EmptyStateText>
@@ -20,6 +20,7 @@ import { GrpcEditor } from "./GrpcEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
interface Props { interface Props {
@@ -47,6 +48,7 @@ interface Props {
const TAB_MESSAGE = "message"; const TAB_MESSAGE = "message";
const TAB_METADATA = "metadata"; const TAB_METADATA = "metadata";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({ export function GrpcRequestPane({
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata"); const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null); const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
{ value: TAB_MESSAGE, label: "Message" }, { value: TAB_MESSAGE, label: "Message" },
...metadataTab, ...metadataTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />, rightSlot: activeRequest.description && <CountBadge count={true} />,
}, },
], ],
[activeRequest.description, authTab, metadataTab], [activeRequest.description, authTab, metadataTab, numSettingsOverrides],
); );
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
onChange={handleMetadataChange} onChange={handleMetadataChange}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}> <TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput <PlainInput
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react"; import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig"; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication"; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl"; import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }), async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode> Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-1"> <EmptyStateText className="flex-col gap-3">
<p> <div className="not-italic flex flex-col items-center gap-3 text-center">
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> <p className="max-w-md text-sm text-text-subtle">
Choose an auth method to apply it to all requests in{" "}
<strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p> </p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link> <AuthenticationTypeDropdown model={model} />
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText> </EmptyStateText>
); );
} }
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth"); if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled == null model.authentication.disabled === false ||
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full" className="w-full"
stateKey={`auth.${model.id}.dynamic`} stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled} value={model.authentication.disabled}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })} onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -169,6 +187,33 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
} }
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({ function AuthenticationDisabledInput({
value, value,
onChange, onChange,
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"} {rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -51,6 +51,7 @@ import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor"; import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -69,6 +70,7 @@ const TAB_BODY = "body";
const TAB_PARAMS = "params"; const TAB_PARAMS = "params";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs"; const TABS_STORAGE_KEY = "http_request_tabs";
@@ -92,6 +94,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL) // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent( useRequestEditorEvent(
@@ -234,6 +237,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
@@ -246,6 +254,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
handleContentTypeChange, handleContentTypeChange,
headersTab, headersTab,
numParams, numParams,
numSettingsOverrides,
urlParameterPairs.length, urlParameterPairs.length,
], ],
); );
@@ -372,6 +381,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}> <ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
@@ -1,10 +1,15 @@
import type { import type {
AnyModel,
HttpResponse, HttpResponse,
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { type ReactNode, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { useAllRequests } from "../hooks/useAllRequests";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow"; import { EventViewerRow } from "./core/EventViewerRow";
@@ -95,6 +100,7 @@ function EventDetails({
}) { }) {
const { label } = getEventDisplay(event.event); const { label } = getEventDisplay(event.event);
const e = event.event; const e = event.event;
const settingSourceModels = useSettingSourceModels();
const actions: EventDetailAction[] = [ const actions: EventDetailAction[] = [
{ {
@@ -211,6 +217,9 @@ function EventDetails({
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow> <KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
{e.source_model != null ? (
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
) : null}
</KeyValueRows> </KeyValueRows>
); );
} }
@@ -315,6 +324,44 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
return includePrefix ? `${prefix} ${text}` : text; return includePrefix ? `${prefix} ${text}` : text;
} }
function useSettingSourceModels() {
const requests = useAllRequests();
const folders = useAtomValue(foldersAtom);
const workspaces = useAtomValue(workspacesAtom);
return useMemo<AnyModel[]>(
() => [...requests, ...folders, ...workspaces],
[requests, folders, workspaces],
);
}
function formatSettingSource(
event: Extract<HttpResponseEventData, { type: "setting" }>,
models: AnyModel[],
): string {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default") {
return "Default";
}
const model =
event.source_id == null
? null
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
const name = model == null ? event.source_name : resolvedModelName(model);
const label = sourceModel.replaceAll("_", " ");
return name == null || name.length === 0 ? label : `${name} (${label})`;
}
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
return null;
}
return sourceModel;
}
type EventDisplay = { type EventDisplay = {
icon: IconProps["icon"]; icon: IconProps["icon"];
color: IconProps["color"]; color: IconProps["color"];
@@ -325,11 +372,12 @@ type EventDisplay = {
function getEventDisplay(event: HttpResponseEventData): EventDisplay { function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) { switch (event.type) {
case "setting": case "setting":
const sourceModel = formatSettingSourceModel(event);
return { return {
icon: "settings", icon: "settings",
color: "secondary", color: "secondary",
label: "Setting", label: "Setting",
summary: `${event.name} = ${event.value}`, summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
}; };
case "info": case "info":
return { return {
@@ -1,6 +1,7 @@
import { VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
@@ -14,6 +15,8 @@ export function ImportDataDialog({ importData }: Props) {
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}> <VStack space={1}>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li> <li>OpenAPI 3.0, 3.1</li>
@@ -0,0 +1,634 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
InheritedBoolSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { useModelAncestors } from "../hooks/useModelAncestors";
import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import {
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingsList,
SettingsSection,
} from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props {
showSectionTitles?: boolean;
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return (
<SettingsList className="space-y-8">
{supportsTlsSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
{supportsHttpSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_TIMEOUT}
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_TIMEOUT.modelKey,
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
)}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_VALIDATE_CERTIFICATES.modelKey,
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchTlsSettings(model, {
settingValidateCertificates,
})
}
/>
{supportsHttpSettings && (
<BooleanSettingRow
settingDefinition={SETTING_FOLLOW_REDIRECTS}
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_FOLLOW_REDIRECTS.modelKey,
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_SEND_COOKIES.modelKey,
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
settingDefinition={SETTING_STORE_COOKIES}
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_STORE_COOKIES.modelKey,
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
)}
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
}
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
}
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
}
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
}
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function patchMessageSizeSettings(
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: boolean;
setting: BooleanSetting;
settingDefinition: RequestSettingDefinition;
onChange: (setting: BooleanSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
<SettingRowBoolean
checked={value}
title={settingDefinition.title}
description={settingDefinition.description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={settingDefinition.title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseInteger(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value}
inputMode="decimal"
step="any"
placeholder={placeholder}
validate={isValidMegabytes}
onChange={onChange}
/>
);
}
function NumberUnitInput({
inputMode,
label,
name,
onChange,
placeholder,
step,
unit,
validate,
value,
}: {
inputMode?: "decimal" | "numeric";
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
step?: number | "any";
unit: string;
validate: (value: string) => boolean;
value: string;
}) {
return (
<PlainInput
hideLabel
name={name}
label={label}
size="sm"
type="number"
inputMode={inputMode}
step={step}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
{unit}
</span>
}
onChange={onChange}
/>
);
}
function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } {
return typeof setting === "object" && setting != null && "value" in setting;
}
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
}
continue;
}
return setting;
}
return isInheritedSetting(fallback) ? fallback.value : fallback;
}
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
const megabytes = Number(value);
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
}
function parseInteger(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function isValidInteger(value: string) {
const parsed = Number(value);
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
}
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
}
+3 -1
View File
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
inline?: boolean; inline?: boolean;
noun?: string; noun?: string;
help?: ReactNode; help?: ReactNode;
hideLabel?: boolean;
label?: ReactNode; label?: ReactNode;
}; };
@@ -36,6 +37,7 @@ export function SelectFile({
size = "sm", size = "sm",
label, label,
help, help,
hideLabel,
...props ...props
}: Props) { }: Props) {
const handleClick = async () => { const handleClick = async () => {
@@ -95,7 +97,7 @@ export function SelectFile({
return ( return (
<div ref={ref} className="w-full"> <div ref={ref} className="w-full">
{label && ( {label && (
<Label htmlFor={null} help={help}> <Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
{label} {label}
</Label> </Label>
)} )}
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner"; import { DetailsBanner } from "../core/DetailsBanner";
@@ -232,6 +233,8 @@ export function SettingsCertificates() {
</HStack> </HStack>
</div> </div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && ( {certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
@@ -2,46 +2,64 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal"; import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Checkbox } from "../core/Checkbox"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow"; import {
import { PlainInput } from "../core/PlainInput"; ModelSettingRowBoolean,
import { Select } from "../core/Select"; ModelSettingSelectControl,
import { Separator } from "../core/Separator"; SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) { if (settings == null) {
return null; return null;
} }
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-4"> <div>
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p> <p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div> </div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater"> <CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1"> <SettingsSection title="Updates">
<Select <SettingRow
name="updateChannel" title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel" label="Update Channel"
labelPosition="left" selectClassName="!w-full"
labelClassName="w-[14rem]"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
options={[ options={[
{ label: "Stable", value: "stable" }, { label: "Stable", value: "stable" },
{ label: "Beta (more frequent)", value: "beta" }, { label: "Beta", value: "beta" },
]} ]}
/> />
<IconButton <IconButton
@@ -53,123 +71,99 @@ export function SettingsGeneral() {
onClick={() => checkForUpdates.mutateAsync()} onClick={() => checkForUpdates.mutateAsync()}
/> />
</div> </div>
</SettingRow>
<Select <SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate" name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"} value={settings.autoupdate ? "auto" : "manual"}
label="Update Behavior" onChange={(v) =>
labelPosition="left" patchModel(settings, { autoupdate: v === "auto" })
size="sm" }
labelClassName="w-[14rem]"
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
options={[ options={[
{ label: "Automatic", value: "auto" }, { label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" }, { label: "Manual", value: "manual" },
]} ]}
/> />
<Checkbox
className="pl-2 mt-1 ml-[14rem]" <ModelSettingRowBoolean
checked={settings.autoDownloadUpdates} model={settings}
disabled={!settings.autoupdate} modelKey="autoDownloadUpdates"
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates" title="Automatically download updates"
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })} description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/> />
<Checkbox <ModelSettingRowBoolean
className="pl-2 mt-1 ml-[14rem]" model={settings}
checked={settings.checkNotifications} modelKey="checkNotifications"
title="Check for notifications" title="Check for notifications"
help="Periodically ping Yaak servers to check for relevant notifications." description="Periodically ping Yaak servers to check for relevant notifications."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/> />
<Checkbox
disabled <SettingRowBoolean
className="pl-2 mt-1 ml-[14rem]"
checked={false}
title="Send anonymous usage statistics" title="Send anonymous usage statistics"
help="Yaak is local-first and does not collect analytics or usage data 🔐" description="Yaak is local-first and does not collect analytics or usage data."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })} disabled
checked={false}
onChange={() => {}}
/> />
</SettingsSection>
</CargoFeature> </CargoFeature>
<Separator className="my-4" /> {showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
<Heading level={2}> id="workspace-settings-moved-2026-06-30"
Workspace{" "} color="info"
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink"> className="p-4 max-w-xl mx-auto"
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<PlainInput
required
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
labelClassName="w-[14rem]"
placeholder="0"
labelPosition="left"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => Number.parseInt(value, 10) >= 0}
onChange={(v) =>
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
}
type="number"
/>
<Checkbox
checked={workspace.settingValidateCertificates}
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
title="Validate TLS certificates"
onChange={(settingValidateCertificates) =>
patchModel(workspace, { settingValidateCertificates })
}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow redirects"
onChange={(settingFollowRedirects) =>
patchModel(workspace, {
settingFollowRedirects,
})
}
/>
</VStack>
<Separator className="my-4" />
<Heading level={2}>App Info</Heading>
<KeyValueRows>
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
<KeyValueRow
label="Data Directory"
rightSlot={
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appDataDir)}
/>
}
> >
{appInfo.appDataDir} <p>
</KeyValueRow> Workspace specific settings have moved to{" "}
<KeyValueRow <b>Workspace Settings</b>, accessible from the workspace switcher
label="Logs Directory" menu.
rightSlot={ </p>
<IconButton </DismissibleBanner>
title={revealInFinderText} )}
icon="folder_open"
size="2xs" <SettingsSection title="App Info">
onClick={() => revealItemInDir(appInfo.appLogDir)} <SettingRow title="Version" description="Current Yaak version.">
/> <SettingValue value={appInfo.version} />
} </SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
> >
{appInfo.appLogDir} <SettingValue
</KeyValueRow> value={appInfo.appDataDir}
</KeyValueRows> actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack> </VStack>
); );
} }
@@ -3,17 +3,27 @@ import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license"; import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models"; import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri"; import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { Select } from "../core/Select"; import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__"; const NULL_FONT_VALUE = "__NULL_FONT__";
@@ -38,16 +48,17 @@ export function SettingsInterface() {
} }
return ( return (
<VStack space={3} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-3"> <div className="mb-3">
<Heading>Interface</Heading> <Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p> <p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div> </div>
<Select <SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
description="Choose what happens when opening another workspace."
name="switchWorkspaceBehavior" name="switchWorkspaceBehavior"
label="Open workspace behavior"
size="sm"
help="When opening a workspace, should it open in the current window or a new window?"
value={ value={
settings.openWorkspaceNewWindow === true settings.openWorkspaceNewWindow === true
? "new" ? "new"
@@ -66,24 +77,25 @@ export function SettingsInterface() {
{ label: "Open in new window", value: "new" }, { label: "Open in new window", value: "new" },
]} ]}
/> />
<HStack space={2} alignItems="end"> </SettingsSection>
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && ( {fonts.data && (
<Select <SettingSelectControl
size="sm"
name="uiFont" name="uiFont"
label="Interface font" label="Interface font"
selectClassName="!w-72"
value={settings.interfaceFont ?? NULL_FONT_VALUE} value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[ options={[
{ label: "System default", value: NULL_FONT_VALUE }, { label: "System default", value: NULL_FONT_VALUE },
...(fonts.data.uiFonts.map((f) => ({ ...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
label: f, ...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
value: f,
})) ?? []),
// Some people like monospace fonts for the UI
...(fonts.data.editorFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
]} ]}
onChange={async (v) => { onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v; const interfaceFont = v === NULL_FONT_VALUE ? null : v;
@@ -91,30 +103,32 @@ export function SettingsInterface() {
}} }}
/> />
)} )}
<Select <SettingSelectControl
hideLabel
size="sm"
name="interfaceFontSize" name="interfaceFontSize"
label="Interface Font Size" label="Interface Font Size"
defaultValue="14" selectClassName="!w-20"
value={`${settings.interfaceFontSize}`} value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions} options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })} onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/> />
</HStack> </SettingRow>
<HStack space={2} alignItems="end">
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && ( {fonts.data && (
<Select <SettingSelectControl
size="sm"
name="editorFont" name="editorFont"
label="Editor font" label="Editor font"
selectClassName="!w-72"
value={settings.editorFont ?? NULL_FONT_VALUE} value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[ options={[
{ label: "System default", value: NULL_FONT_VALUE }, { label: "System default", value: NULL_FONT_VALUE },
...(fonts.data.editorFonts.map((f) => ({ ...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
label: f,
value: f,
})) ?? []),
]} ]}
onChange={async (v) => { onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v; const editorFont = v === NULL_FONT_VALUE ? null : v;
@@ -122,70 +136,84 @@ export function SettingsInterface() {
}} }}
/> />
)} )}
<Select <SettingSelectControl
hideLabel
size="sm"
name="editorFontSize" name="editorFontSize"
label="Editor Font Size" label="Editor Font Size"
defaultValue="12" selectClassName="!w-20"
value={`${settings.editorFontSize}`} value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions} options={fontSizeOptions}
onChange={(v) => onChange={(v) =>
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) }) patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
} }
/> />
</HStack> </SettingRow>
<Select </SettingsSection>
leftSlot={<Icon icon="keyboard" color="secondary" />}
size="sm" <SettingsSection title="Editor">
name="editorKeymap" <ModelSettingRowSelect
label="Editor keymap" model={settings}
value={`${settings.editorKeymap}`} modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps} options={keymaps}
onChange={(v) => patchModel(settings, { editorKeymap: v })}
/> />
<Checkbox <ModelSettingRowBoolean
checked={settings.editorSoftWrap} model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines" title="Wrap editor lines"
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })} description="Wrap long lines in request and response editors."
/> />
<Checkbox <ModelSettingRowBoolean
checked={settings.coloredMethods} model={settings}
modelKey="coloredMethods"
title="Colorize request methods" title="Colorize request methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })} description="Use method-specific colors for HTTP request methods."
/> />
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license"> <CargoFeature feature="license">
<LicenseSettings settings={settings} /> <LicenseSettings settings={settings} />
</CargoFeature> </CargoFeature>
</SettingsList>
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<Checkbox
checked={settings.hideWindowControls}
title="Hide window controls"
help="Hide the close/maximize/minimize controls on Windows or Linux"
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
/>
)}
</VStack> </VStack>
); );
} }
function NativeTitlebarSetting({ settings }: { settings: Settings }) { function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar); const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return ( return (
<div className="flex gap-1 overflow-hidden h-2xs"> <SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox <Checkbox
hideLabel
size="md"
checked={nativeTitlebar} checked={nativeTitlebar}
title="Native title bar" title="Native title bar"
help="Use the operating system's standard title bar and window controls"
onChange={setNativeTitlebar} onChange={setNativeTitlebar}
/> />
{settings.useNativeTitlebar !== nativeTitlebar && ( {settings.useNativeTitlebar !== nativeTitlebar && (
<Button <Button
color="primary" color="primary"
size="2xs" size="xs"
onClick={async () => { onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar }); await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart"); await invokeCmd("cmd_restart");
@@ -194,7 +222,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
Apply and Restart Apply and Restart
</Button> </Button>
)} )}
</div> </SettingRow>
); );
} }
@@ -205,9 +233,11 @@ function LicenseSettings({ settings }: { settings: Settings }) {
} }
return ( return (
<Checkbox <SettingsSection title="License">
<SettingRowBoolean
checked={settings.hideLicenseBadge} checked={settings.hideLicenseBadge}
title="Hide personal use badge" title="Hide personal use badge"
description="Hide the personal-use badge from the interface."
onChange={async (hideLicenseBadge) => { onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) { if (hideLicenseBadge) {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
@@ -223,7 +253,9 @@ function LicenseSettings({ settings }: { settings: Settings }) {
</p> </p>
<p> <p>
Licenses help keep Yaak independent and sustainable.{" "} Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link> <Link href={pricingUrl("app.license.badge-hide-confirm")}>
Purchase a License
</Link>
</p> </p>
</VStack> </VStack>
), ),
@@ -231,11 +263,12 @@ function LicenseSettings({ settings }: { settings: Settings }) {
color: "info", color: "info",
}); });
if (!confirmed) { if (!confirmed) {
return; // Cancel return;
} }
} }
await patchModel(settings, { hideLicenseBadge }); await patchModel(settings, { hideLicenseBadge });
}} }}
/> />
</SettingsSection>
); );
} }
@@ -6,6 +6,7 @@ import { formatDate } from "date-fns/format";
import { useState } from "react"; import { useState } from "react";
import { useToggle } from "../../hooks/useToggle"; import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize"; import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span> <span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")} onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl( openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -1,13 +1,29 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui"; import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Checkbox } from "../core/Checkbox"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { PlainInput } from "../core/PlainInput"; import {
import { Select } from "../core/Select"; SettingRowBoolean,
import { Separator } from "../core/Separator"; SettingRowSelect,
SettingRowText,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsProxy() { export function SettingsProxy() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const proxy = enabledProxyOrDefault(settings.proxy);
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
await patchModel(settings, {
proxy: {
...proxy,
...patch,
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
},
});
};
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
@@ -18,26 +34,19 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<Select <CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
title="Proxy"
description="Choose how Yaak should discover or use proxy settings."
name="proxy" name="proxy"
label="Proxy"
hideLabel
size="sm"
value={settings.proxy?.type ?? "automatic"} value={settings.proxy?.type ?? "automatic"}
onChange={async (v) => { onChange={async (v) => {
if (v === "automatic") { if (v === "automatic") {
await patchModel(settings, { proxy: undefined }); await patchModel(settings, { proxy: undefined });
} else if (v === "enabled") { } else if (v === "enabled") {
await patchModel(settings, { await patchModel(settings, { proxy });
proxy: {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
},
});
} else { } else {
await patchModel(settings, { proxy: { type: "disabled" } }); await patchModel(settings, { proxy: { type: "disabled" } });
} }
@@ -47,159 +56,125 @@ export function SettingsProxy() {
{ label: "Custom proxy configuration", value: "enabled" }, { label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" }, { label: "No proxy", value: "disabled" },
]} ]}
selectClassName="!w-64"
/> />
</SettingsSection>
{settings.proxy?.type === "enabled" && ( {settings.proxy?.type === "enabled" && (
<VStack space={1.5}> <>
<Checkbox <SettingsSection title="Custom Proxy">
className="my-3" <SettingRowBoolean
checked={!settings.proxy.disabled} checked={!settings.proxy.disabled}
title="Enable proxy" title="Enable proxy"
help="Use this to temporarily disable the proxy without losing the configuration" description="Temporarily disable the proxy without losing the configuration."
onChange={async (enabled) => { onChange={(enabled) => patchProxy({ disabled: !enabled })}
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = !enabled;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
<HStack space={1.5}> <SettingRowText
<PlainInput name="proxyHttp"
size="sm" title={
label={
<> <>
Proxy for <InlineCode>http://</InlineCode> traffic Proxy for <InlineCode>http://</InlineCode> traffic
</> </>
} }
description="Proxy host used for unencrypted HTTP traffic."
value={settings.proxy.http}
placeholder="localhost:9090" placeholder="localhost:9090"
defaultValue={settings.proxy?.http} onChange={(http) => patchProxy({ http })}
onChange={async (http) => {
const { proxy } = settings;
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: {
type: "enabled",
http,
https,
auth,
disabled,
bypass,
},
});
}}
/> />
<PlainInput <SettingRowText
size="sm" name="proxyHttps"
label={ title={
<> <>
Proxy for <InlineCode>https://</InlineCode> traffic Proxy for <InlineCode>https://</InlineCode> traffic
</> </>
} }
description="Proxy host used for HTTPS traffic."
value={settings.proxy.https}
placeholder="localhost:9090" placeholder="localhost:9090"
defaultValue={settings.proxy?.https} onChange={(https) => patchProxy({ https })}
onChange={async (https) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
</HStack> <SettingRowText
<Separator className="my-6" /> name="proxyBypass"
<Checkbox title="Proxy Bypass"
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
inputWidthClassName="!w-96"
onChange={(bypass) => patchProxy({ bypass })}
/>
</SettingsSection>
<SettingsSection title="Authentication">
<SettingRowBoolean
checked={settings.proxy.auth != null} checked={settings.proxy.auth != null}
title="Enable authentication" title="Enable authentication"
onChange={async (enabled) => { description="Send proxy credentials with proxied requests."
const { proxy } = settings; onChange={(enabled) =>
const http = proxy?.type === "enabled" ? proxy.http : ""; patchProxy({ auth: enabled ? { user: "", password: "" } : null })
const https = proxy?.type === "enabled" ? proxy.https : ""; }
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = enabled ? { user: "", password: "" } : null;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
{settings.proxy.auth != null && ( {settings.proxy.auth != null && (
<HStack space={1.5}>
<PlainInput
required
size="sm"
label="User"
placeholder="myUser"
defaultValue={settings.proxy.auth.user}
onChange={async (user) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
<PlainInput
size="sm"
label="Password"
type="password"
placeholder="s3cretPassw0rd"
defaultValue={settings.proxy.auth.password}
onChange={async (password) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
</HStack>
)}
{settings.proxy.type === "enabled" && (
<> <>
<Separator className="my-6" /> <SettingRowText
<PlainInput required
label="Proxy Bypass" name="proxyUser"
help="Comma-separated list to bypass the proxy." title="User"
defaultValue={settings.proxy.bypass} description="Username for proxy authentication."
placeholder="127.0.0.1, *.example.com, localhost:3000" value={settings.proxy.auth.user}
onChange={async (bypass) => { placeholder="myUser"
const { proxy } = settings; onChange={(user) =>
const http = proxy?.type === "enabled" ? proxy.http : ""; patchProxy({
const https = proxy?.type === "enabled" ? proxy.https : ""; auth: {
const disabled = proxy?.type === "enabled" ? proxy.disabled : false; user,
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : ""; password:
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : ""; settings.proxy?.type === "enabled"
const auth = { user, password }; ? (settings.proxy.auth?.password ?? "")
await patchModel(settings, { : "",
proxy: { type: "enabled", http, https, auth, disabled, bypass }, },
}); })
}} }
/>
<SettingRowText
name="proxyPassword"
title="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password"
onChange={(password) =>
patchProxy({
auth: {
user:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.user ?? "")
: "",
password,
},
})
}
/> />
</> </>
)} )}
</VStack> </SettingsSection>
</>
)} )}
</SettingsList>
</VStack> </VStack>
); );
} }
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
if (proxy?.type === "enabled") return proxy;
return {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
};
}
@@ -9,7 +9,12 @@ import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select"; import type { SelectProps } from "../core/Select";
import { Select } from "../core/Select"; import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor }))); const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
@@ -67,7 +72,7 @@ export function SettingsTheme() {
})); }));
return ( return (
<VStack space={3} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-3"> <div className="mb-3">
<Heading>Theme</Heading> <Heading>Theme</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">
@@ -77,51 +82,45 @@ export function SettingsTheme() {
</Link> </Link>
</p> </p>
</div> </div>
<Select <SettingsList className="space-y-8">
name="appearance" <SettingsSection title="Theme">
label="Appearance" <ModelSettingRowSelect
labelPosition="top" model={settings}
size="sm" modelKey="appearance"
value={settings.appearance} title="Appearance"
onChange={(appearance) => patchModel(settings, { appearance })} description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[ options={[
{ label: "Automatic", value: "system" }, { label: "Automatic", value: "system" },
{ label: "Light", value: "light" }, { label: "Light", value: "light" },
{ label: "Dark", value: "dark" }, { label: "Dark", value: "dark" },
]} ]}
/> />
<HStack space={2}>
{(settings.appearance === "system" || settings.appearance === "light") && ( {(settings.appearance === "system" || settings.appearance === "light") && (
<Select <SettingRowSelect
hideLabel
leftSlot={<Icon icon="sun" color="secondary" />}
name="lightTheme" name="lightTheme"
label="Light Theme" title="Light theme"
size="sm" description="Theme used when Yaak is in light mode."
className="flex-1"
value={activeTheme.data.light.id} value={activeTheme.data.light.id}
options={lightThemes} options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })} onChange={(themeLight) => patchModel(settings, { themeLight })}
/> />
)} )}
{(settings.appearance === "system" || settings.appearance === "dark") && ( {(settings.appearance === "system" || settings.appearance === "dark") && (
<Select <SettingRowSelect
hideLabel
name="darkTheme" name="darkTheme"
className="flex-1" title="Dark theme"
label="Dark Theme" description="Theme used when Yaak is in dark mode."
leftSlot={<Icon icon="moon" color="secondary" />}
size="sm"
value={activeTheme.data.dark.id} value={activeTheme.data.dark.id}
options={darkThemes} options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })} onChange={(themeDark) => patchModel(settings, { themeDark })}
/> />
)} )}
</HStack> </SettingsSection>
<SettingsSection title="Preview">
<VStack <VStack
space={3} space={3}
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto" className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
> >
<HStack className="text" space={1.5}> <HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} /> <Icon icon={appearance === "dark" ? "moon" : "sun"} />
@@ -167,6 +166,8 @@ export function SettingsTheme() {
/> />
</Suspense> </Suspense>
</VStack> </VStack>
</SettingsSection>
</SettingsList>
</VStack> </VStack>
); );
} }
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData"; import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown"; import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui"; import { Icon } from "@yaakapp-internal/ui";
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active", hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />, leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />, rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () => openUrl("https://yaak.app/pricing"), onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
+112 -6
View File
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown"; import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension"; import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension"; import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query"; import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag"; import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { import {
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import type { InputHandle } from "./core/Input"; import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input"; import { Input } from "./core/Input";
import { EmptyStateText } from "./EmptyStateText";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage"; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { GitDropdown } from "./git/GitDropdown"; import { GitDropdown } from "./git/GitDropdown";
import { gitCallbacks } from "./git/callbacks"; import { gitCallbacks } from "./git/callbacks";
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom); const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? []; const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null); const filterRef = useRef<InputHandle>(null);
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
); );
const clearFilterText = useCallback(() => { const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` }); setSidebarFilterText("");
requestAnimationFrame(() => { requestAnimationFrame(() => {
filterRef.current?.focus(); filterRef.current?.focus();
}); });
@@ -252,6 +255,13 @@ function Sidebar({ className }: { className?: string }) {
[], [],
); );
const applyFilterExample = useCallback((text: string) => {
setSidebarFilterText(text);
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback( const getSelectedTreeModels = useCallback(
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
)} )}
</div> </div>
{allHidden ? ( {allHidden ? (
<div className="italic text-text-subtle p-3 text-sm text-center"> <div className="p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode> {(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results, but found matches for{" "}
{emptyFilterSuggestions?.map((suggestion, i) => (
<span key={suggestion.field}>
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{suggestion.filterText}
</InlineCode>
</button>
</span>
))}
</div>
</EmptyStateText>
) : (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results for{" "}
<InlineCode className="inline-block max-w-36 truncate align-middle">
{filterText.text}
</InlineCode>
</div>
</EmptyStateText>
)}
</div> </div>
) : ( ) : (
<Tree <Tree
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "", key: "",
}); });
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => { type SidebarFilterSuggestion = {
field: string;
filterText: string;
};
function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
return null;
}
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
}
const sidebarSuggestionFieldOrder = [
"url",
"folder",
"method",
"type",
"grpc_service",
"grpc_method",
"name",
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom); const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom); const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom); const filter = get(sidebarFilterAtom);
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
} }
const queryAst = parseQuery(filter.text); const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter // returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {}; const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => { const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? []; const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true; let matchesSelf = true;
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
if (!value) continue; if (!value) continue;
allFields[field] = allFields[field] ?? new Set(); allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value); allFields[field].add(value);
if (
isLeafNode &&
suggestionValue != null &&
sidebarFieldMatchesValue(value, suggestionValue)
) {
suggestionFields.add(field);
}
} }
if (queryAst != null) { if (queryAst != null) {
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
values: Array.from(values).filter((v) => v.length < 20), values: Array.from(values).filter((v) => v.length < 20),
}); });
} }
return [root, fields] as const; const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
})
.map((field) => ({
field,
filterText: formatFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
}); });
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => { const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -4,20 +4,79 @@ import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
export interface SyncToFilesystemSettingProps { export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void; onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void; onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean }; value: { filePath: string | null; initGit?: boolean };
} }
export function SyncToFilesystemSetting({ export function SyncToFilesystemSetting({
layout = "form",
onChange, onChange,
onCreateNewWorkspace, onCreateNewWorkspace,
value, value,
}: SyncToFilesystemSettingProps) { }: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null); const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return ( return (
<VStack className="w-full my-2" space={3}> <VStack className="w-full my-2" space={3}>
{syncDir && ( {syncDir && (
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
noun="Directory" noun="Directory"
help="Sync data to a folder for backup and Git integration." help="Sync data to a folder for backup and Git integration."
filePath={value.filePath} filePath={value.filePath}
onChange={async ({ filePath }) => { onChange={async ({ filePath }) => handleFilePathChange(filePath)}
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
}}
/> />
{value.filePath && typeof value.initGit === "boolean" && ( {value.filePath && typeof value.initGit === "boolean" && (
@@ -34,6 +34,7 @@ import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -48,6 +49,7 @@ const TAB_MESSAGE = "message";
const TAB_PARAMS = "params"; const TAB_PARAMS = "params";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs"; const TABS_STORAGE_KEY = "websocket_request_tabs";
@@ -69,6 +71,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL) // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent( useRequestEditorEvent(
@@ -109,12 +112,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: "Info", label: "Info",
}, },
]; ];
}, [authTab, headersTab, urlParameterPairs.length]); }, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -266,6 +274,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`json.${activeRequest.id}`} stateKey={`json.${activeRequest.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}> <TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput <PlainInput
@@ -105,10 +105,18 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary"; messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "close" || messageType === "open" messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -119,6 +127,8 @@ function WebsocketEventRow({
"Disconnected from server" "Disconnected from server"
) : messageType === "open" ? ( ) : messageType === "open" ? (
"Connected to server" "Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? ( ) : message === "" ? (
<em className="italic text-text-subtlest">No content</em> <em className="italic text-text-subtlest">No content</em>
) : ( ) : (
@@ -170,6 +180,8 @@ function WebsocketEventDetail({
? "Connection Closed" ? "Connection Closed"
: event.messageType === "open" : event.messageType === "open"
? "Connection Open" ? "Connection Open"
: event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`; : `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] = const actions: EventDetailAction[] =
@@ -6,6 +6,7 @@ import { useAtomValue } from "jotai";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
getActiveCookieJar,
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId, useSubscribeActiveCookieJarId,
} from "../hooks/useActiveCookieJar"; } from "../hooks/useActiveCookieJar";
@@ -33,6 +34,7 @@ import { jotaiStore } from "../lib/jotai";
import { CreateDropdown } from "./CreateDropdown"; import { CreateDropdown } from "./CreateDropdown";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList"; import { HotkeyList } from "./core/HotkeyList";
import { CookieDialog } from "./CookieDialog";
import { FeedbackLink } from "./core/Link"; import { FeedbackLink } from "./core/Link";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout"; import { FolderLayout } from "./FolderLayout";
@@ -218,4 +220,8 @@ function useGlobalWorkspaceHooks() {
useHotKey("model.duplicate", () => useHotKey("model.duplicate", () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)), duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
); );
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
enable: () => getActiveCookieJar() != null,
});
} }
@@ -20,16 +20,24 @@ import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip"; import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label"; import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp"; import { EncryptionHelp } from "./EncryptionHelp";
interface Props { interface Props {
layout?: "form" | "settings";
size?: ButtonProps["size"]; size?: ButtonProps["size"];
expanded?: boolean; expanded?: boolean;
onDone?: () => void; onDone?: () => void;
onEnabledEncryption?: () => void; onEnabledEncryption?: () => void;
} }
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) { export function WorkspaceEncryptionSetting({
layout = "form",
size,
expanded,
onDone,
onEnabledEncryption,
}: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false); const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
key.error != null || key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) { ) {
return ( const enterKey = (
<EnterWorkspaceKey <EnterWorkspaceKey
workspaceMeta={workspaceMeta} workspaceMeta={workspaceMeta}
error={key.error} error={key.error}
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
}} }}
/> />
); );
return enterKey;
} }
// Show the key if it exists // Show the key if it exists
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
encryptionKey={key.key} encryptionKey={key.key}
/> />
); );
return (
const content = (
<VStack space={2} className="w-full"> <VStack space={2} className="w-full">
{justEnabledEncryption && ( {justEnabledEncryption && (
<Banner color="success" className="flex flex-col gap-2"> <Banner color="success" className="flex flex-col gap-2">
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
)} )}
</VStack> </VStack>
); );
return content;
} }
// Show button to enable encryption // Show button to enable encryption
if (layout === "settings") {
return (
<>
{error && (
<Banner color="danger" className="mb-3">
{error}
</Banner>
)}
<SettingRow
title="Workspace encryption"
description="Encrypt workspace secrets and sensitive values at rest."
>
<Button
color="secondary"
size={size}
onClick={async () => {
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError(`Failed to enable encryption: ${String(err)}`);
}
}}
>
Enable Encryption
</Button>
</SettingRow>
</>
);
}
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
@@ -1,20 +1,23 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { CopyIconButton } from "./CopyIconButton"; import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs"; import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { DnsOverridesEditor } from "./DnsOverridesEditor"; import { DnsOverridesEditor } from "./DnsOverridesEditor";
import { HeadersEditor } from "./HeadersEditor"; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { ModelSettingsEditor } from "./ModelSettingsEditor";
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting"; import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting"; import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
@@ -25,17 +28,17 @@ interface Props {
} }
const TAB_AUTH = "auth"; const TAB_AUTH = "auth";
const TAB_DATA = "data";
const TAB_DNS = "dns"; const TAB_DNS = "dns";
const TAB_HEADERS = "headers"; const TAB_HEADERS = "headers";
const TAB_GENERAL = "general"; const TAB_GENERAL = "general";
const TAB_SETTINGS = "settings";
export type WorkspaceSettingsTab = export type WorkspaceSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
| typeof TAB_DNS | typeof TAB_DNS
| typeof TAB_HEADERS | typeof TAB_HEADERS
| typeof TAB_GENERAL | typeof TAB_GENERAL
| typeof TAB_DATA; | typeof TAB_SETTINGS;
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL; const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
tabs={[ tabs={[
{ value: TAB_GENERAL, label: "Workspace" }, { value: TAB_GENERAL, label: "Workspace" },
{ {
value: TAB_DATA, value: TAB_SETTINGS,
label: "Storage", label: "Settings",
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
@@ -100,6 +103,22 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
stateKey={`headers.${workspace.id}`} stateKey={`headers.${workspace.id}`}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full"> <div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
<PlainInput <PlainInput
@@ -152,19 +171,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
</HStack> </HStack>
</div> </div>
</TabContent> </TabContent>
<TabContent value={TAB_DATA} className="overflow-y-auto h-full px-4">
<VStack space={4} alignItems="start" className="pb-3 h-full">
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<WorkspaceEncryptionSetting size="xs" />
</VStack>
</TabContent>
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
<DnsOverridesEditor workspace={workspace} /> <DnsOverridesEditor workspace={workspace} />
</TabContent> </TabContent>
</Tabs> </Tabs>
); );
} }
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({
id: "workspace-settings",
size: "lg",
className: "h-[calc(100vh-5rem)] !max-h-[50rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vite-plus/test";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
expect(parseBulkPairLine("foo: bar")).toMatchObject({
enabled: true,
name: "foo",
value: "bar",
});
});
test("preserves colon-without-space lines as a name with an empty value", () => {
expect(parseBulkPairLine("foo:bar")).toMatchObject({
enabled: true,
name: "foo:bar",
value: "",
});
});
test("preserves malformed lines instead of dropping their contents", () => {
expect(parseBulkPairLine("not a pair")).toMatchObject({
enabled: true,
name: "not a pair",
value: "",
});
});
test("unescapes newlines in parsed values", () => {
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
enabled: true,
name: "foo",
value: "bar\nbaz",
});
});
});
@@ -17,7 +17,7 @@ export function BulkPairEditor({
const pairsText = useMemo(() => { const pairsText = useMemo(() => {
return pairs return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === "")) .filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(pairToLine) .map(formatBulkPairLine)
.join("\n"); .join("\n");
}, [pairs]); }, [pairs]);
@@ -26,7 +26,7 @@ export function BulkPairEditor({
const pairs = text const pairs = text
.split("\n") .split("\n")
.filter((l: string) => l.trim()) .filter((l: string) => l.trim())
.map(lineToPair); .map(parseBulkPairLine);
onChange(pairs); onChange(pairs);
}, },
[onChange], [onChange],
@@ -47,16 +47,16 @@ export function BulkPairEditor({
); );
} }
function pairToLine(pair: Pair) { export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n"); const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`; return `${pair.name}: ${value}`;
} }
function lineToPair(line: string): PairWithId { export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? []; const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return { return {
enabled: true, enabled: true,
name: (name ?? "").trim(), name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(), value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(), id: generateId(),
}; };
@@ -13,6 +13,7 @@ export interface CheckboxProps {
hideLabel?: boolean; hideLabel?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
help?: ReactNode; help?: ReactNode;
size?: "sm" | "md";
} }
export function Checkbox({ export function Checkbox({
@@ -25,6 +26,7 @@ export function Checkbox({
hideLabel, hideLabel,
fullWidth, fullWidth,
help, help,
size = "sm",
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
<HStack <HStack
@@ -37,7 +39,9 @@ export function Checkbox({
<input <input
aria-hidden aria-hidden
className={classNames( className={classNames(
"appearance-none w-4 h-4 flex-shrink-0 border border-border", "appearance-none flex-shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded outline-none ring-0", "rounded outline-none ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]", !disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted", disabled && "border-dotted",
@@ -50,7 +54,7 @@ export function Checkbox({
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Icon <Icon
size="sm" size={size}
className={classNames(disabled && "opacity-disabled")} className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"} icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/> />
@@ -1,39 +1,73 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner, HStack } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
actions?: { label: string; onClick: () => void; color?: Color }[]; onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
}) { }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({ const {
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
if (dismissed) return null; const shouldShow = !isLoading && !dismissed;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
return ( return (
<Banner <Banner className={classNames(className, "relative")} {...props}>
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")} <div className="@container">
{...props} <div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
>
{children} {children}
<HStack space={1.5}> <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => {
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
}}
title="Dismiss message"
>
Dismiss
</Button>
{actions?.map((a) => ( {actions?.map((a) => (
<Button <Button
key={a.label} key={a.label}
variant="border" variant={a.variant ?? "border"}
color={a.color ?? props.color} color={a.color ?? props.color}
size="xs" size="xs"
onClick={a.onClick} onClick={a.onClick}
@@ -42,16 +76,9 @@ export function DismissibleBanner({
{a.label} {a.label}
</Button> </Button>
))} ))}
<Button </div>
variant="border" </div>
color={props.color} </div>
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
>
Dismiss
</Button>
</HStack>
</Banner> </Banner>
); );
} }
@@ -580,6 +580,10 @@ function getExtensions({
return [ return [
...baseExtensions, // Must be first ...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
}),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
onFocus.current?.(); onFocus.current?.();
@@ -15,8 +15,9 @@ export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}] fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
} }
const IDENT = /[A-Za-z0-9_/]+$/; const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/; const VALUE_IDENT = /\S+$/;
const VALUE_IDENT_ONLY = /^\S+$/;
function normalizeFields(fields: FieldDef[]): { function normalizeFields(fields: FieldDef[]): {
fieldNames: string[]; fieldNames: string[];
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap }; return { fieldNames, fieldMap };
} }
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null { function wordBefore(
doc: string,
pos: number,
pattern: RegExp,
): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos); const upto = doc.slice(0, pos);
const m = upto.match(IDENT); const m = upto.match(pattern);
if (!m) return null; if (!m) return null;
const from = pos - m[0].length; const from = pos - m[0].length;
return { from, to: pos, text: m[0] }; return { from, to: pos, text: m[0] };
} }
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
function inPhrase(ctx: CompletionContext): boolean { function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token // Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1); let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
if (inValue) { if (inValue) {
// word before the colon = field name // word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon); const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(IDENT); const m = beforeColon.match(FIELD_IDENT);
fieldName = m ? m[0] : null; fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon? // nothing (or only spaces) typed after the colon?
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
} }
/** Build a completion list for field names */ /** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[]): Completion[] { function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
return fieldNames.map((name) => ({ return fieldNames.map((name) => ({
label: name, label: name,
type: "property", type: "property",
apply: (view, _completion, from, to) => { apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon) // Leave cursor right after the field filter colon.
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({ view.dispatch({
changes: { from, to, insert: `${name}:` }, changes: { from, to, insert },
selection: { anchor: from + name.length + 1 }, selection: { anchor: from + insert.length },
}); });
startCompletion(view); startCompletion(view);
}, },
@@ -115,7 +140,7 @@ function fieldValueCompletions(
if (!def || !def.values) return null; if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values(); const vals = Array.isArray(def.values) ? def.values : def.values();
return vals.map((v) => ({ return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`, label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v, displayLabel: v,
type: "constant", type: "constant",
})); }));
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
return null; return null;
} }
const w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos); const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position // In field value position
if (inValue && fieldName) { if (inValue && fieldName) {
const w = wordBefore(doc, pos, VALUE_IDENT);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName]; const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs); const vals = fieldValueCompletions(valDefs);
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
} }
// Not in a value: suggest field names (and maybe boolean ops) // Not in a value: suggest field names (and maybe boolean ops)
const options: Completion[] = fieldNameCompletions(fieldNames); const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
return { from, to, options, filter: true }; return { from, to, options, filter: true };
}; };
@@ -2,10 +2,11 @@
@skip { space+ } @skip { space+ }
@tokens { @tokens {
space { std.whitespace+ } space { $[ \t\r\n]+ }
LParen { "(" } LParen { "(" }
RParen { ")" } RParen { ")" }
At { "@" }
Colon { ":" } Colon { ":" }
Not { "-" | "NOT" } Not { "-" | "NOT" }
@@ -16,8 +17,10 @@
// "quoted phrase" with simple escapes: \" and \\ // "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' } Phrase { '"' (!["\\] | "\\" _)* '"' }
// field/word characters (keep generous for URLs/paths) // Bare words run until filter syntax or whitespace. Leading '-' remains unary
Word { $[A-Za-z0-9_]+ } // negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word } @precedence { Not, And, Or, Word }
} }
@@ -60,12 +63,12 @@ Field {
} }
FieldName { FieldName {
Word At? Word
} }
FieldValue { FieldValue {
Phrase Phrase
| Term | FieldValueWord
} }
Term { Term {
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,27 +1,22 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it. // 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: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p", stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
stateData: goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~", nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne", maxTerm: 27,
nodeNames:
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [ nodeProps: [
["openedBy", 8, "LParen"], ["openedBy", 8,"LParen"],
["closedBy", 9, "RParen"], ["closedBy", 9,"RParen"]
], ],
propSources: [highlight], propSources: [highlight],
skippedNodes: [0, 20], skippedNodes: [0,22],
repeatNodeCount: 3, repeatNodeCount: 3,
tokenData: tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[", tokenizers: [0, 1],
tokenizers: [0], topRules: {"Query":[0,1]},
topRules: { Query: [0, 1] }, tokenPrec: 145
tokenPrec: 145, })
});
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -0,0 +1,7 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -16,6 +16,7 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string" Phrase: t.string, // "quoted string"
// Fields // Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName, "FieldName/Word": t.attributeName,
"FieldValue/Term/Word": t.attributeValue, "FieldValue/FieldValueWord": t.attributeValue,
}); });
@@ -30,7 +30,8 @@ type Tok =
| { kind: "EOF" }; | { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c); const isSpace = (c: string) => /\s/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c); const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
export function tokenize(input: string): Tok[] { export function tokenize(input: string): Tok[] {
const toks: Tok[] = []; const toks: Tok[] = [];
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
const readWord = () => { const readWord = () => {
let s = ""; let s = "";
while (i < n && isIdent(peek())) s += advance(); while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s; return s;
}; };
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
if (c === ":") { if (c === ":") {
toks.push({ kind: "COLON" }); toks.push({ kind: "COLON" });
i++; i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue; continue;
} }
if (c === `"`) { if (c === `"`) {
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
} }
// WORD / AND / OR / NOT // WORD / AND / OR / NOT
if (isIdent(c)) { if (isWordStart(c)) {
const w = readWord(); const w = readWord();
const upper = w.toUpperCase(); const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" }); if (upper === "AND") toks.push({ kind: "AND" });
@@ -1,7 +1,7 @@
@top pairs { (Key Sep Value "\n")* } @top pairs { (Key Sep Value "\n")* }
@tokens { @tokens {
Sep { ":" } Sep { ":" $[ \t]+ }
Key { ":"? ![:]+ } Key { ":"? ![:]+ }
Value { ![\n]+ } Value { ![\n]+ }
} }
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./pairs";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "pairs") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("pairs grammar", () => {
test("parses colon-space pairs with a value", () => {
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
});
test("does not parse colon-without-space as a value", () => {
const nodes = getNodeNames("foo:bar\n");
expect(nodes).not.toContain("Value");
});
});
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 1, repeatNodeCount: 1,
tokenData: tokenData:
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh", "%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
tokenizers: [0, 1, 2], tokenizers: [0, 1, 2],
topRules: { pairs: [0, 1] }, topRules: { pairs: [0, 1] },
tokenPrec: 0, tokenPrec: 0,
@@ -240,7 +240,13 @@ export function EventDetailHeader({
</HStack> </HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}> <Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon} {action.icon}
{action.label} {action.label}
</Button> </Button>
+3 -3
View File
@@ -290,10 +290,10 @@ function BaseInput({
<HStack <HStack
className={classNames( className={classNames(
inputWrapperClassName, inputWrapperClassName,
"w-full min-w-0 px-2", "flex-1 min-w-0 px-2",
fullHeight && "h-full", fullHeight && "h-full",
leftSlot ? "pl-0.5 -ml-2" : null, leftSlot ? "pl-0" : null,
rightSlot ? "pr-0.5 -mr-2" : null, rightSlot ? "pr-0" : null,
)} )}
> >
<Editor <Editor
@@ -1,16 +1,24 @@
import classNames from "classnames"; import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react"; import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
interface Props { interface Props {
children: children:
| ReactElement<HTMLAttributes<HTMLTableColElement>> | ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[]; | (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
} }
export function KeyValueRows({ children }: Props) { export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return ( return (
<table className="text-editor font-mono min-w-0 w-full mb-auto"> <table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight"> <tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => ( {childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key // oxlint-disable-next-line react/no-array-index-key
@@ -26,8 +34,11 @@ interface KeyValueRowProps {
children: ReactNode; children: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
leftSlot?: ReactNode; leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string; labelClassName?: string;
labelColor?: "secondary" | "primary" | "info"; labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
} }
export function KeyValueRow({ export function KeyValueRow({
@@ -35,14 +46,36 @@ export function KeyValueRow({
children, children,
rightSlot, rightSlot,
leftSlot, leftSlot,
align = "top",
labelColor = "secondary", labelColor = "secondary",
labelClassName, labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) { }: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={copyTitle}
iconSize="sm"
/>
) : null);
return ( return (
<> <>
<td <td
className={classNames( className={classNames(
"select-none py-0.5 pr-2 h-full align-top max-w-[10rem]", "select-none py-0.5 pr-2 h-full max-w-[10rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName, labelClassName,
labelColor === "primary" && "text-primary", labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle", labelColor === "secondary" && "text-text-subtle",
@@ -51,11 +84,21 @@ export function KeyValueRow({
> >
<span className="select-text cursor-text">{label}</span> <span className="select-text cursor-text">{label}</span>
</td> </td>
<td className="select-none py-0.5 break-all align-top max-w-[15rem]"> <td
className={classNames(
"select-none py-0.5 break-all max-w-[15rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]"> <div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />} {leftSlot ?? <span aria-hidden />}
{children} {children}
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />} {resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div> </div>
</td> </td>
</> </>
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui"; import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react"; import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -28,10 +28,9 @@ export type PlainInputProps = Omit<
| "extraExtensions" | "extraExtensions"
| "forcedEnvironmentId" | "forcedEnvironmentId"
> & > &
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & { Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"]; onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number"; type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean; hideObscureToggle?: boolean;
labelRightSlot?: ReactNode; labelRightSlot?: ReactNode;
}; };
@@ -43,6 +42,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
className, className,
containerClassName, containerClassName,
defaultValue, defaultValue,
disabled,
forceUpdateKey: forceUpdateKeyFromAbove, forceUpdateKey: forceUpdateKeyFromAbove,
help, help,
hideLabel, hideLabel,
@@ -51,6 +51,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName, labelClassName,
labelPosition = "top", labelPosition = "top",
labelRightSlot, labelRightSlot,
inputMode,
leftSlot, leftSlot,
name, name,
onBlur, onBlur,
@@ -63,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required, required,
rightSlot, rightSlot,
size = "md", size = "md",
step,
tint, tint,
type = "text", type = "text",
validate, validate,
@@ -163,7 +165,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
"relative w-full rounded-md text", "relative w-full rounded-md text",
"border", "border",
"overflow-hidden", "overflow-hidden",
focused ? "border-border-focus" : "border-border-subtle", focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation
size === "md" && "min-h-md", size === "md" && "min-h-md",
size === "sm" && "min-h-sm", size === "sm" && "min-h-sm",
@@ -198,15 +201,18 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
// oxlint-disable-next-line jsx-a11y/no-autofocus // oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined} defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))} onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full")} className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
required={required} required={required}
step={step}
placeholder={placeholder} placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
/> />
+9 -1
View File
@@ -109,7 +109,15 @@ export function Select<T extends string>({
) : ( ) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in // Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode // light mode
<RadioDropdown value={value} onChange={handleChange} items={options}> <RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button <Button
className="w-full text-sm font-mono" className="w-full text-sm font-mono"
justify="start" justify="start"
@@ -24,7 +24,7 @@ export function Separator({
)} )}
<div <div
className={classNames( className={classNames(
"h-0 border-t opacity-60", "opacity-60",
color == null && "border-border", color == null && "border-border",
color === "primary" && "border-primary", color === "primary" && "border-primary",
color === "secondary" && "border-secondary", color === "secondary" && "border-secondary",
@@ -34,8 +34,8 @@ export function Separator({
color === "danger" && "border-danger", color === "danger" && "border-danger",
color === "info" && "border-info", color === "info" && "border-info",
dashed && "border-dashed", dashed && "border-dashed",
orientation === "horizontal" && "w-full h-[1px]", orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-[1px]", orientation === "vertical" && "h-full w-0 border-l",
)} )}
/> />
</div> </div>
@@ -0,0 +1,514 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
return (
<SettingRowBoolean
checked={model[modelKey] as boolean}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "!w-48",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={String(modelKey)}
value={model[modelKey] as number}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "!w-80",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={String(modelKey)}
value={model[modelKey] as string}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowFile({
buttonClassName,
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
directory,
filePath,
nameOverride,
noun,
onChange,
size = "xs",
title,
...props
}: {
buttonClassName?: string;
directory?: boolean;
filePath: string | null;
nameOverride?: string | null;
noun?: string;
onChange: (filePath: string | null) => void | Promise<void>;
size?: Parameters<typeof SelectFile>[0]["size"];
} & SettingRowBaseProps) {
return (
<SettingRow title={title} controlClassName={controlClassName} {...props}>
<SelectFile
directory={directory}
inline
hideLabel
label={typeof title === "string" ? title : noun}
size={size}
noun={noun}
nameOverride={nameOverride}
filePath={filePath}
className={buttonClassName}
onChange={({ filePath }) => onChange(filePath)}
/>
</SettingRow>
);
}
export function SettingRowDirectory({
noun = "Directory",
...props
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "!w-48",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "!w-48",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function ModelSettingRowSelect<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
@@ -1,6 +1,6 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git"; import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git"; import type { GitCommit } from "@yaakapp-internal/git";
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui"; import { SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -8,7 +8,7 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui"; import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml"; import { modelToYaml } from "../../lib/diffYaml";
@@ -16,6 +16,7 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync"; import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
@@ -205,7 +206,8 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full px-4"> <div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
@@ -596,7 +596,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
color: "success", color: "success",
label: "Open Workspace Settings", label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />, leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("data"), onSelect: () => openWorkspaceSettings("settings"),
}, },
{ type: "separator" }, { type: "separator" },
{ {
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote", title: "Add Remote",
inputs: [ inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName }, { type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url" }, { type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
], ],
}); });
if (r == null) throw new Error("Cancelled remote prompt"); if (r == null) throw new Error("Cancelled remote prompt");
@@ -69,6 +69,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
text={text} text={text}
language={language} language={language}
stateKey={`response.body.${response.id}`} stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty} pretty={pretty}
className={className} className={className}
onFilter={filterCallback} onFilter={filterCallback}
@@ -16,6 +16,7 @@ interface Props {
text: string; text: string;
language: EditorProps["language"]; language: EditorProps["language"];
stateKey: string | null; stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean; pretty?: boolean;
className?: string; className?: string;
onFilter?: (filter: string) => { onFilter?: (filter: string) => {
@@ -27,16 +28,25 @@ interface Props {
const useFilterText = createGlobalState<Record<string, string | null>>({}); const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) { export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
onFilter,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText(); const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText); const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback( const setFilterText = useCallback(
(v: string | null) => { (v: string | null) => {
if (!stateKey) return; if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v })); setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
}, },
[setFilterTextMap, stateKey], [filterKey, setFilterTextMap],
); );
const isSearching = filterText != null; const isSearching = filterText != null;
@@ -64,7 +74,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
nodes.push( nodes.push(
<div key="input" className="w-full !opacity-100"> <div key="input" className="w-full !opacity-100">
<Input <Input
key={stateKey ?? "filter"} key={filterKey ?? "filter"}
validate={!filteredResponse.error} validate={!filteredResponse.error}
hideLabel hideLabel
autoFocus autoFocus
@@ -76,7 +86,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
defaultValue={filterText} defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()} onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText} onChange={setFilterText}
stateKey={stateKey ? `filter.${stateKey}` : null} stateKey={filterKey ? `filter.${filterKey}` : null}
/> />
</div>, </div>,
); );
@@ -97,12 +107,12 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
return nodes; return nodes;
}, [ }, [
canFilter, canFilter,
filterKey,
filterText, filterText,
filteredResponse.error, filteredResponse.error,
filteredResponse.isPending, filteredResponse.isPending,
isSearching, isSearching,
language, language,
stateKey,
setFilterText, setFilterText,
toggleSearch, toggleSearch,
]); ]);
+63 -26
View File
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip"; import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs"; import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize"; import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
@@ -14,19 +15,37 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication"; import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors"; import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) { export function useAuthTab<T extends string>(
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries(); const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model); const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null; const parentModel = ancestors[0] ?? null;
return useMemo<TabItem[]>(() => { return useMemo(() => {
if (model == null) return []; if (model == null) return null;
const tab: TabItem = { return {
value: tabValue,
label: "Auth",
options: {
value: model.authenticationType, value: model.authenticationType,
items: [ items: [
...authentication.map((a) => ({ ...authentication.map((a) => ({
@@ -38,12 +57,14 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
{ {
label: "Inherit from Parent", label: "Inherit from Parent",
shortLabel: shortLabel:
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? ( inheritedAuth != null &&
inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}> <HStack space={1.5}>
{authentication.find((a) => a.name === inheritedAuth.authenticationType) {authentication.find(
?.shortLabel ?? "UNKNOWN"} (a) => a.name === inheritedAuth.authenticationType,
)?.shortLabel ?? "UNKNOWN"}
<IconTooltip <IconTooltip
icon="magic_wand" icon="zap_off"
iconSize="xs" iconSize="xs"
content="Authentication was inherited from an ancestor" content="Authentication was inherited from an ancestor"
/> />
@@ -58,7 +79,11 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
itemsAfter: (() => { itemsAfter: (() => {
const actions: ( const actions: (
| { type: "separator"; label: string } | { type: "separator"; label: string }
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> } | {
label: string;
leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
}
)[] = []; )[] = [];
// Promote: move auth from current model up to parent // Promote: move auth from current model up to parent
@@ -66,7 +91,8 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
parentModel && parentModel &&
model.authenticationType && model.authenticationType &&
model.authenticationType !== "none" && model.authenticationType !== "none" &&
(parentModel.authenticationType == null || parentModel.authenticationType === "none") (parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) { ) {
actions.push( actions.push(
{ type: "separator", label: "Actions" }, { type: "separator", label: "Actions" },
@@ -74,7 +100,11 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
label: `Promote to ${capitalize(parentModel.model)}`, label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: ( leftSlot: (
<Icon <Icon
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"} icon={
parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
}
/> />
), ),
onSelect: async () => { onSelect: async () => {
@@ -90,7 +120,10 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
), ),
}); });
if (confirmed) { if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null }); await patchModel(model, {
authentication: {},
authenticationType: null,
});
await patchModel(parentModel, { await patchModel(parentModel, {
authentication: model.authentication, authentication: model.authentication,
authenticationType: model.authenticationType, authenticationType: model.authenticationType,
@@ -109,7 +142,8 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
// Copy from ancestor: copy auth config down to current model // Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find( const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== "none", (a) =>
a.authenticationType != null && a.authenticationType !== "none",
); );
if (ancestorWithAuth) { if (ancestorWithAuth) {
if (actions.length === 0) { if (actions.length === 0) {
@@ -120,7 +154,9 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
leftSlot: ( leftSlot: (
<Icon <Icon
icon={ icon={
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down" ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
} }
/> />
), ),
@@ -132,11 +168,15 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
description: ( description: (
<> <>
Copy{" "} Copy{" "}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType) {authentication.find(
?.label ?? "authentication"}{" "} (a) => a.name === ancestorWithAuth.authenticationType,
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>? )?.label ?? "authentication"}{" "}
This will override the current authentication but will not affect the{" "} config from{" "}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}. <InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</> </>
), ),
}); });
@@ -161,9 +201,6 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
} }
await patchModel(model, { authentication, authenticationType }); await patchModel(model, { authentication, authenticationType });
}, },
},
}; };
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
+4
View File
@@ -14,6 +14,7 @@ export type HotkeyAction =
| "app.zoom_out" | "app.zoom_out"
| "app.zoom_reset" | "app.zoom_reset"
| "command_palette.toggle" | "command_palette.toggle"
| "cookies_editor.show"
| "editor.autocomplete" | "editor.autocomplete"
| "environment_editor.toggle" | "environment_editor.toggle"
| "hotkeys.showHelp" | "hotkeys.showHelp"
@@ -43,6 +44,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Meta+Minus"], "app.zoom_out": ["Meta+Minus"],
"app.zoom_reset": ["Meta+0"], "app.zoom_reset": ["Meta+0"],
"command_palette.toggle": ["Meta+k"], "command_palette.toggle": ["Meta+k"],
"cookies_editor.show": ["Meta+Shift+k"],
"editor.autocomplete": ["Control+Space"], "editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Meta+Shift+e"], "environment_editor.toggle": ["Meta+Shift+e"],
"request.rename": ["Control+Shift+r"], "request.rename": ["Control+Shift+r"],
@@ -73,6 +75,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Control+Minus"], "app.zoom_out": ["Control+Minus"],
"app.zoom_reset": ["Control+0"], "app.zoom_reset": ["Control+0"],
"command_palette.toggle": ["Control+k"], "command_palette.toggle": ["Control+k"],
"cookies_editor.show": ["Control+Shift+k"],
"editor.autocomplete": ["Control+Space"], "editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Control+Shift+e"], "environment_editor.toggle": ["Control+Shift+e"],
"request.rename": ["F2"], "request.rename": ["F2"],
@@ -128,6 +131,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
"app.zoom_out": "Zoom Out", "app.zoom_out": "Zoom Out",
"app.zoom_reset": "Zoom to Actual Size", "app.zoom_reset": "Zoom to Actual Size",
"command_palette.toggle": "Toggle Command Palette", "command_palette.toggle": "Toggle Command Palette",
"cookies_editor.show": "Show Cookies",
"editor.autocomplete": "Trigger Autocomplete", "editor.autocomplete": "Trigger Autocomplete",
"environment_editor.toggle": "Edit Environments", "environment_editor.toggle": "Edit Environments",
"hotkeys.showHelp": "Show Keyboard Shortcuts", "hotkeys.showHelp": "Show Keyboard Shortcuts",
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Appearance } from "../lib/theme/appearance"; import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance"; import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
export function usePreferredAppearance() { export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance()); const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
@@ -1,6 +1,6 @@
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() { export function useResolvedAppearance() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/theme/themes"; import { getResolvedTheme, getThemes } from "../lib/themes";
import { usePluginsKey } from "./usePlugins"; import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
+13 -21
View File
@@ -1,40 +1,32 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models"; import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment"; import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation"; import { createFastMutation, useFastMutation } from "./useFastMutation";
export function useSendAnyHttpRequest() { async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
return useFastMutation<HttpResponse | null, string, string | null>({ if (id == null) {
mutationKey: ["send_any_request"],
mutationFn: async (id) => {
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null; return null;
} }
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", { return invokeCmd("cmd_send_http_request", {
request, requestId: id,
environmentId: getActiveEnvironment()?.id, environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id, cookieJarId: getActiveCookieJar()?.id,
}); });
}, }
export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
}); });
} }
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({ export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"], mutationKey: ["send_any_request"],
mutationFn: async (id) => { mutationFn: sendAnyHttpRequestById,
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null;
}
return invokeCmd("cmd_send_http_request", {
request,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
},
}); });
@@ -35,10 +35,15 @@ export async function deleteModelWithConfirm(
<> <>
the following? the following?
<Prose className="mt-2"> <Prose className="mt-2">
<ul> <ul className="space-y-1">
{models.map((m) => ( {models.map((m) => (
<li key={m.id}> <li key={m.id}>
<InlineCode>{resolvedModelName(m)}</InlineCode> <InlineCode
className="inline-block truncate align-bottom max-w-full"
title={resolvedModelName(m)}
>
{resolvedModelName(m)}
</InlineCode>
</li> </li>
))} ))}
</ul> </ul>
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
color: "danger", color: "danger",
timeout: null, timeout: null,
message: `Failed to load plugin "${name}": ${err}`, message: `Failed to load plugin "${name}": ${err}`,
action: ({ hide }) => (
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
hide();
openSettings.mutate("plugins:installed");
}}
>
Manage Plugins
</Button>
),
}); });
} }
}); });
+3
View File
@@ -0,0 +1,3 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
+101
View File
@@ -0,0 +1,101 @@
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
models: readonly ModelTypeWithSetting<K>[];
title: string;
};
export type RequestSettingKey = keyof WorkspaceRequestSettings;
function defineRequestSetting<const K extends RequestSettingKey>(
setting: RequestSettingDefinition<K>,
) {
return setting;
}
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
defaultValue: 0,
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
modelKey: "settingRequestTimeout",
models: ["workspace", "folder", "http_request"],
title: "Request Timeout",
});
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates",
});
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
defaultValue: true,
description: "Follow HTTP redirects automatically.",
modelKey: "settingFollowRedirects",
models: ["workspace", "folder", "http_request"],
title: "Follow redirects",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
});
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
});
export function modelSupportsSetting<K extends RequestSettingKey>(
model: Pick<AnyModel, "model">,
setting: RequestSettingDefinition<K>,
) {
return setting.models.some((modelType) => modelType === model.model);
}
-8
View File
@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";
-9
View File
@@ -1,9 +0,0 @@
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";
-1
View File
@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";
@@ -1,10 +1,11 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins"; import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; import {
import { invokeCmd } from "../tauri"; defaultDarkTheme,
import type { Appearance } from "./appearance"; defaultLightTheme,
import { resolveAppearance } from "./appearance"; resolveAppearance,
type Appearance,
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; } from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
export async function getThemes() { export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes); const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
+4 -4
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.0.1", "react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.0", "react-use": "^17.6.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -98,15 +98,15 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"decompress": "^4.2.1", "decompress": "^4.2.1",
"internal-ip": "^8.0.0", "internal-ip": "^8.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.14",
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
"rollup": "^4.60.3", "rollup": "^4.60.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plugin-static-copy": "^3.3.0", "vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0", "vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+7 -4
View File
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window"; import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models"; import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme";
import {
applyThemeToDocument,
getCSSAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings"; import { getSettings } from "./lib/settings";
import type { Appearance } from "./lib/theme/appearance"; import { getResolvedTheme } from "./lib/themes";
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want // NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long // a good appearance guess so we're not waiting too long
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
}), }),
], ],
build: { build: {
target: "esnext",
sourcemap: true, sourcemap: true,
outDir: "../../dist/apps/yaak-client", outDir: "../../dist/apps/yaak-client",
emptyOutDir: true, emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+1
View File
@@ -42,6 +42,7 @@ webbrowser = "1"
zip = "4" zip = "4"
yaak = { workspace = true } yaak = { workspace = true }
yaak-api = { workspace = true } yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
+38
View File
@@ -42,6 +42,12 @@ pub enum Commands {
/// Authentication commands /// Authentication commands
Auth(AuthArgs), Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
Export(ExportArgs),
/// Plugin development and publishing commands /// Plugin development and publishing commands
Plugin(PluginArgs), Plugin(PluginArgs),
@@ -92,6 +98,34 @@ pub struct SendArgs {
pub fail_fast: bool, pub fail_fast: bool,
} }
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
pub workspace_id: Option<String>,
}
#[derive(Args)]
pub struct ExportArgs {
/// Path to write the Yaak export JSON file
pub file: PathBuf,
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
#[arg(value_name = "WORKSPACE_ID")]
pub workspace_ids: Vec<String>,
/// Export all workspaces
#[arg(long, conflicts_with = "workspace_ids")]
pub all: bool,
/// Include private environments in the export
#[arg(long)]
pub include_private_environments: bool,
}
#[derive(Args)] #[derive(Args)]
#[command(disable_help_subcommand = true)] #[command(disable_help_subcommand = true)]
pub struct CookieJarArgs { pub struct CookieJarArgs {
@@ -447,6 +481,10 @@ pub enum PluginCommands {
/// Install a plugin from a local directory or from the registry /// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs), Install(InstallPluginArgs),
/// Generate plugin metadata for the registry
#[command(hide = true)]
Metadata(PluginPathArg),
/// Publish a Yaak plugin version to the plugin registry /// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg), Publish(PluginPathArg),
} }
@@ -0,0 +1,176 @@
use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await {
Ok(result) => {
println!("Imported {}", format_counts(&result));
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
match export(ctx, args) {
Ok(count) => {
println!("Exported {count} workspace(s)");
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
return Err(
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
export::export_data(ExportDataParams {
query_manager: ctx.query_manager(),
yaak_version: env!("CARGO_PKG_VERSION"),
export_path: &args.file,
workspace_ids: workspace_id_refs,
include_private_environments: args.include_private_environments,
})
.map_err(|e| format!("Failed to export data: {e}"))?;
Ok(workspace_ids.len())
}
fn resolve_export_workspace_ids(
ctx: &CliContext,
workspace_ids: Vec<String>,
all: bool,
) -> CommandResult<Vec<String>> {
if all {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
return Err("No workspaces found to export".to_string());
}
return Ok(workspaces.into_iter().map(|w| w.id).collect());
}
if workspace_ids.is_empty() {
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
}
for workspace_id in &workspace_ids {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
e.workspace_id == "CURRENT_WORKSPACE"
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.folders.iter().any(|f| {
f.workspace_id == "CURRENT_WORKSPACE"
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.http_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.grpc_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.websocket_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
}
fn format_counts(result: &BatchUpsertResult) -> String {
let names = [
"workspace",
"environment",
"folder",
"HTTP request",
"gRPC request",
"WebSocket request",
];
let counts = [
(result.workspaces.len(), names[0]),
(result.environments.len(), names[1]),
(result.folders.len(), names[2]),
(result.http_requests.len(), names[3]),
(result.grpc_requests.len(), names[4]),
(result.websocket_requests.len(), names[5]),
];
let non_zero: Vec<String> = counts
.into_iter()
.filter(|(count, _)| *count > 0)
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
.collect();
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
}
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod cookie_jar; pub mod cookie_jar;
pub mod environment; pub mod environment;
pub mod folder; pub mod folder;
pub mod import_export;
pub mod plugin; pub mod plugin;
pub mod request; pub mod request;
pub mod send; pub mod send;
+184 -2
View File
@@ -13,6 +13,7 @@ use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
type CommandResult<T = ()> = std::result::Result<T, String>; type CommandResult<T = ()> = std::result::Result<T, String>;
const KEYRING_USER: &str = "yaak"; const KEYRING_USER: &str = "yaak";
const METADATA_NODE_BIN: &str = "node";
const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packages/plugin-runtime/.node-version"
));
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment { enum Environment {
@@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 {
} }
} }
pub async fn run_metadata(args: PluginPathArg) -> i32 {
match metadata(args) {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
async fn build(args: PluginPathArg) -> CommandResult { async fn build(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display())); ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
Ok(()) Ok(())
} }
fn metadata(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!(
"Generated plugin metadata at {}",
plugin_dir.join("build/metadata.json").display()
));
Ok(())
}
async fn dev(args: PluginPathArg) -> CommandResult { async fn dev(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
}); });
ui::info(&format!("Rebuilding plugin {display_path}")); ui::info(&format!("Rebuilding plugin {display_path}"));
} }
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {} WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
match generate_plugin_metadata(&watch_root) {
Ok(()) => ui::success(&format!(
"Generated plugin metadata at {}",
watch_root.join("build/metadata.json").display()
)),
Err(error) => ui::error(&error),
}
}
WatcherEvent::Event(BundleEvent::Error(event)) => { WatcherEvent::Event(BundleEvent::Error(event)) => {
if event.error.diagnostics.is_empty() { if event.error.diagnostics.is_empty() {
ui::error("Plugin build failed"); ui::error("Plugin build failed");
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::info("Archiving plugin"); ui::info("Archiving plugin");
let archive = create_publish_archive(&plugin_dir)?; let archive = create_publish_archive(&plugin_dir)?;
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect()) Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
} }
fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult {
let entry_path = plugin_dir.join("build/index.js");
if !entry_path.is_file() {
return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string());
}
ensure_metadata_node_version()?;
let metadata_path = plugin_dir.join("build/metadata.json");
let output = Command::new(METADATA_NODE_BIN)
.arg("-e")
.arg(METADATA_SCRIPT)
.arg(entry_path.canonicalize().map_err(|e| {
format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display())
})?)
.arg(&metadata_path)
.current_dir(plugin_dir)
.output()
.map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
format!("Node.js exited with status {}", output.status)
} else {
stderr
};
return Err(format!("Failed to generate plugin metadata: {message}"));
}
Ok(())
}
fn ensure_metadata_node_version() -> CommandResult {
let minimum_major = PLUGIN_RUNTIME_NODE_VERSION
.trim()
.trim_start_matches('v')
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| {
format!(
"Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version",
PLUGIN_RUNTIME_NODE_VERSION.trim()
)
})?;
let output = Command::new(METADATA_NODE_BIN)
.arg("--version")
.output()
.map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?;
if !output.status.success() {
return Err(format!(
"`{METADATA_NODE_BIN} --version` failed with status {}",
output.status
));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
let major = version
.trim_start_matches('v')
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?;
if major >= minimum_major {
return Ok(());
}
Err(format!("Node.js {minimum_major} or newer is required. Found {version}."))
}
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult { fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build"); let build_dir = plugin_dir.join("build");
if build_dir.exists() { if build_dir.exists() {
@@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{
} }
"#; "#;
const METADATA_SCRIPT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packages/plugin-runtime/src/metadata.ts"
));
const TEMPLATE_TSCONFIG: &str = r#"{ const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": { "compilerOptions": {
"target": "es2021", "target": "es2021",
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::create_publish_archive; use super::{create_publish_archive, generate_plugin_metadata};
use serde_json::Value;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::Cursor; use std::io::Cursor;
@@ -659,6 +774,7 @@ mod tests {
.expect("write src/index.ts"); .expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n") fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js"); .expect("write build/index.js");
fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json");
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file"); fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive"); let archive = create_publish_archive(root).expect("create archive");
@@ -673,8 +789,74 @@ mod tests {
assert!(names.contains("README.md")); assert!(names.contains("README.md"));
assert!(names.contains("package.json")); assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json")); assert!(names.contains("package-lock.json"));
assert!(names.contains("build/metadata.json"));
assert!(names.contains("src/index.ts")); assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js")); assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt")); assert!(!names.contains("ignored/secret.txt"));
} }
#[test]
fn generate_plugin_metadata_detects_api_types() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(
root.join("build/index.js"),
r##"
exports.plugin = {
themes: [{
id: "midnight",
label: "Midnight",
dark: true,
base: { surface: "#000000", text: "#ffffff" },
}],
templateFunctions: [{
name: "signature",
description: "Create a signature",
args: [{ type: "text", name: "secret", dynamic() {} }],
onRender() {},
}],
workspaceActions: [{
label: "Sync workspace",
icon: "info",
onSelect() {},
}],
folderActions: [{
label: "Export folder",
icon: "copy",
onSelect() {},
}],
async init() {},
};
"##,
)
.expect("write build/index.js");
generate_plugin_metadata(root).expect("generate metadata");
let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata");
let metadata: Value = serde_json::from_str(&contents).expect("metadata json");
let api_types = metadata["apiTypes"].as_array().expect("apiTypes array");
for expected in [
"folderActions",
"templateFunctions",
"themes",
"workspaceActions",
"lifecycle",
] {
assert!(
api_types.iter().any(|value| value.as_str() == Some(expected)),
"missing api type {expected}: {api_types:?}"
);
}
assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight");
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
assert!(
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
);
}
} }
+18
View File
@@ -37,11 +37,29 @@ async fn main() {
let exit_code = match command { let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await, Commands::Auth(args) => commands::auth::run(args).await,
Commands::Import(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
let execution_context = CliExecutionContext {
workspace_id: args.workspace_id.clone(),
..CliExecutionContext::default()
};
context.init_plugins(execution_context).await;
let exit_code = commands::import_export::run_import(&context, args).await;
context.shutdown().await;
exit_code
}
Commands::Export(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::import_export::run_export(&context, args);
context.shutdown().await;
exit_code
}
Commands::Plugin(args) => match args.command { Commands::Plugin(args) => match args.command {
PluginCommands::Build(args) => commands::plugin::run_build(args).await, PluginCommands::Build(args) => commands::plugin::run_build(args).await,
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await, PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await, PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await, PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
PluginCommands::Install(install_args) => { PluginCommands::Install(install_args) => {
let mut context = CliContext::new(data_dir.clone(), app_id); let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await; context.init_plugins(CliExecutionContext::default()).await;
+1 -7
View File
@@ -473,7 +473,7 @@ async fn build_plugin_reply(
let names = cookie_jar let names = cookie_jar
.cookies .cookies
.into_iter() .into_iter()
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name)) .map(|c| c.name)
.collect(); .collect();
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
@@ -531,12 +531,6 @@ async fn render_json_value_for_cli<T: TemplateCallback>(
render_json_value_raw(value, vars, cb, opt).await render_json_value_raw(value, vars, cb, opt).await
} }
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
let first_part = raw_cookie.split(';').next()?.trim();
let (name, value) = first_part.split_once('=')?;
Some((name.trim().to_string(), value.to_string()))
}
fn copy_text_to_clipboard(text: &str) -> Result<(), String> { fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
clipboard.set_text(text.to_string()).map_err(|e| e.to_string()) clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
@@ -0,0 +1,162 @@
mod common;
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
#[test]
fn export_writes_yaak_workspace_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let export_path = temp_dir.path().join("export.json");
let create_assert =
cli_cmd(data_dir).args(["workspace", "create", "--name", "Export Me"]).assert().success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
seed_request(data_dir, &workspace_id, "req_export");
cli_cmd(data_dir)
.args([
"export",
export_path.to_str().expect("export path is utf-8"),
&workspace_id,
])
.assert()
.success()
.stdout(contains("Exported 1 workspace(s)"));
let exported: Value = serde_json::from_str(
&std::fs::read_to_string(export_path).expect("export file should exist"),
)
.expect("export should be JSON");
assert_eq!(exported["yaakSchema"], 4);
assert_eq!(exported["resources"]["workspaces"][0]["id"], workspace_id);
assert_eq!(exported["resources"]["httpRequests"][0]["id"], "req_export");
}
#[test]
fn import_reads_yaak_workspace_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("import.json");
std::fs::write(
&import_path,
r#"{
"yaakVersion": "test",
"yaakSchema": 4,
"resources": {
"workspaces": [
{
"model": "workspace",
"id": "wrk_import",
"name": "Imported Workspace"
}
],
"httpRequests": [
{
"model": "http_request",
"id": "req_import",
"workspaceId": "wrk_import",
"name": "Imported Request",
"method": "GET",
"url": "https://example.com"
}
]
}
}"#,
)
.expect("write import fixture");
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
}
fn write_postman_environment_fixture(path: &std::path::Path) {
std::fs::write(
path,
r#"{
"name": "Local",
"_postman_variable_scope": "environment",
"values": [
{
"key": "token",
"value": "abc123",
"enabled": true
}
]
}"#,
)
.expect("write postman environment fixture");
}
#[test]
fn import_postman_environment_requires_workspace_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("postman-env.json");
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
write_postman_environment_fixture(&import_path);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.failure()
.stderr(contains("requires a workspace context"))
.stderr(contains("--workspace-id"));
}
#[test]
fn import_postman_environment_uses_workspace_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("postman-env.json");
let create_assert =
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
write_postman_environment_fixture(&import_path);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Imported 1 environment"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let environments =
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
let imported_environment =
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
assert_eq!(imported_environment.workspace_id, workspace_id);
}
@@ -7,34 +7,6 @@ use tempfile::TempDir;
use yaak_models::models::HttpRequest; use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
#[test]
fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let server = TestHttpServer::spawn_ok("workspace bulk send");
let request = HttpRequest {
id: "rq_workspace_send".to_string(),
workspace_id: "wk_test".to_string(),
name: "Workspace Send".to_string(),
method: "GET".to_string(),
url: server.url.clone(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed workspace request");
cli_cmd(data_dir)
.args(["send", "wk_test"])
.assert()
.success()
.stdout(contains("workspace bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
#[test] #[test]
fn top_level_send_folder_sends_http_requests_and_prints_summary() { fn top_level_send_folder_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -38,6 +38,9 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
ApiError(#[from] yaak_api::Error), ApiError(#[from] yaak_api::Error),
#[error(transparent)]
YaakError(#[from] yaak::Error),
#[error(transparent)] #[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error), ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
+5 -1
View File
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime, is_dev};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";
@@ -36,6 +36,10 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
..Default::default() ..Default::default()
}; };
if is_dev() {
info.current_version = "0.0.1".to_string();
}
app_handle app_handle
.with_tx(|tx| { .with_tx(|tx| {
// Load the previously tracked version // Load the previously tracked version
+12 -105
View File
@@ -1,16 +1,12 @@
use crate::PluginContextExt; use crate::PluginContextExt;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use log::info;
use std::collections::BTreeMap;
use std::fs::read_to_string; use std::fs::read_to_string;
use std::io::ErrorKind; use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak_core::WorkspaceContext; use yaak_core::WorkspaceContext;
use yaak_models::models::{ use yaak_models::util::BatchUpsertResult;
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
file_path: &str, file_path: &str,
) -> Result<BatchUpsertResult> { ) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>(); let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let file = read_import_file(file_path)?; let file = read_import_file(file_path)?;
let file_contents = file.as_str(); let plugin_context = window.plugin_context();
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?; let workspace_context = WorkspaceContext {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
// Create WorkspaceContext from window
let ctx = WorkspaceContext {
workspace_id: window.workspace_id(), workspace_id: window.workspace_id(),
environment_id: window.environment_id(), environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(), cookie_jar_id: window.cookie_jar_id(),
request_id: None, request_id: None,
}; };
let resources = import_result.resources; Ok(import::import_data(ImportDataParams {
query_manager: &query_manager,
let workspaces: Vec<Workspace> = resources plugin_manager: &plugin_manager,
.workspaces plugin_context: &plugin_context,
.into_iter() workspace_context,
.map(|mut v| { contents: &file,
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
v
}) })
.collect(); .await?)
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
}
("", _) => {
// Fix any empty ones
v.parent_model = "workspace".to_string();
}
_ => {
// Parent ID only required for the folder case
v.parent_id = None;
}
};
v
})
.collect();
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
let upserted = window.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
&UpdateSource::Import,
)
})?;
Ok(upserted)
} }
fn read_import_file(file_path: &str) -> Result<String> { fn read_import_file(file_path: &str) -> Result<String> {
+59 -39
View File
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE}; use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time; use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak_common::command::new_checked_command; use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle}; use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -41,7 +41,7 @@ use yaak_models::models::{
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace, GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta, WorkspaceMeta,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs, CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
}; };
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::PluginMetadata; use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent; use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -330,8 +331,9 @@ async fn cmd_grpc_reflect<R: Runtime>(
&uri, &uri,
&proto_files, &proto_files,
&metadata, &metadata,
workspace.setting_validate_certificates, resolved_settings.validate_certificates.value,
client_certificate, client_certificate,
resolved_settings.request_message_size.value,
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -423,8 +426,9 @@ async fn cmd_grpc_go<R: Runtime>(
uri.as_str(), uri.as_str(),
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(), &proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata, &metadata,
workspace.setting_validate_certificates, resolved_settings.validate_certificates.value,
client_cert.clone(), client_cert.clone(),
resolved_settings.request_message_size.value,
) )
.await; .await;
@@ -714,7 +718,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -722,7 +726,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -738,7 +742,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -781,7 +785,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -789,7 +793,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -806,7 +810,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -878,7 +882,8 @@ async fn cmd_grpc_go<R: Runtime>(
.db() .db()
.upsert_grpc_event( .upsert_grpc_event(
&GrpcEvent { &GrpcEvent {
content: status.to_string(), content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32), status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()), metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
@@ -887,6 +892,7 @@ async fn cmd_grpc_go<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
) )
.unwrap(); .unwrap();
break;
} }
} }
} }
@@ -1384,24 +1390,14 @@ async fn cmd_export_data<R: Runtime>(
workspace_ids: Vec<&str>, workspace_ids: Vec<&str>,
include_private_environments: bool, include_private_environments: bool,
) -> YaakResult<()> { ) -> YaakResult<()> {
let db = app_handle.db();
let version = app_handle.package_info().version.to_string(); let version = app_handle.package_info().version.to_string();
let export_data = Ok(export::export_data(ExportDataParams {
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?; query_manager: &app_handle.db_manager(),
let f = File::options() yaak_version: &version,
.create(true) export_path: Path::new(export_path),
.truncate(true) workspace_ids,
.write(true) include_private_environments,
.open(export_path) })?)
.expect("Unable to create file");
serde_json::to_writer_pretty(&f, &export_data)
.map_err(|e| GenericError(e.to_string()))
.expect("Failed to write");
f.sync_all().expect("Failed to sync");
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -1425,11 +1421,10 @@ async fn cmd_send_http_request<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
// NOTE: We receive the entire request because to account for the race request_id: String,
// condition where the user may have just edited a field before sending
// that has not yet been saved in the DB.
request: HttpRequest,
) -> YaakResult<HttpResponse> { ) -> YaakResult<HttpResponse> {
let request = app_handle.db().get_http_request(&request_id)?;
let blobs = app_handle.blob_manager(); let blobs = app_handle.blob_manager();
let response = app_handle.db().upsert_http_response( let response = app_handle.db().upsert_http_response(
&HttpResponse { &HttpResponse {
@@ -1512,11 +1507,36 @@ async fn cmd_plugin_info<R: Runtime>(
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
) -> YaakResult<PluginMetadata> { ) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?; let plugin = app_handle.db().get_plugin(id)?;
Ok(plugin_manager if let Some(plugin_handle) = plugin_manager
.get_plugin_by_dir(plugin.directory.as_str()) .get_plugin_by_dir(plugin.directory.as_str())
.await .await
.ok_or(GenericError("Failed to find plugin for info".to_string()))? {
.info()) return Ok(plugin_handle.info());
}
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
return Ok(metadata);
}
Ok(fallback_plugin_metadata(&plugin.directory))
}
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
let display_name = PathBuf::from(directory)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(directory)
.to_string();
PluginMetadata {
version: "Unavailable".to_string(),
name: directory.to_string(),
display_name,
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
homepage_url: None,
repository_url: None,
}
} }
#[tauri::command] #[tauri::command]
@@ -79,7 +79,7 @@ impl YaakNotifier {
return Ok(()); return Ok(());
} }
debug!("Checking for notifications"); info!("Checking for notifications");
#[cfg(feature = "license")] #[cfg(feature = "license")]
let license_check = { let license_check = {
@@ -115,17 +115,20 @@ impl YaakNotifier {
]); ]);
let resp = req.send().await?; let resp = req.send().await?;
if resp.status() != 200 { if resp.status() != 200 {
debug!("Skipping notification status code {}", resp.status()); info!("Skipping notification status code {}", resp.status());
return Ok(()); return Ok(());
} }
for notification in resp.json::<Vec<YaakNotification>>().await? { let notifications = resp.json::<Vec<YaakNotification>>().await?;
debug!("Received {} notifications", notifications.len());
for notification in notifications {
let seen = get_kv(app_handle).await?; let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) { if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id); debug!("Already seen notification {}", notification.id);
continue; continue;
} }
debug!("Got notification {:?}", notification); info!("Got notification {:?}", notification);
let _ = app_handle.emit_to(window.label(), "notification", notification.clone()); let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
break; // Only show one notification break; // Only show one notification
@@ -8,7 +8,6 @@ use crate::{
workspace_from_window, workspace_from_window,
}; };
use chrono::Utc; use chrono::Utc;
use cookie::Cookie;
use log::error; use log::error;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime}; use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
@@ -409,11 +408,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let window = get_window_from_plugin_context(app_handle, plugin_context)?; let window = get_window_from_plugin_context(app_handle, plugin_context)?;
let names = match cookie_jar_from_window(&window) { let names = match cookie_jar_from_window(&window) {
None => Vec::new(), None => Vec::new(),
Some(j) => j Some(j) => j.cookies.into_iter().map(|c| c.name).collect(),
.cookies
.into_iter()
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
.collect(),
}; };
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
names, names,
+64 -7
View File
@@ -50,6 +50,37 @@ pub async fn cmd_ws_send<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let connection = app_handle.db().get_websocket_connection(connection_id)?; let connection = app_handle.db().get_websocket_connection(connection_id)?;
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
.await
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
Ok(connection)
}
}
}
async fn send_websocket_message<R: Runtime>(
connection: &WebsocketConnection,
environment_id: Option<&str>,
app_handle: &AppHandle<R>,
window: &WebviewWindow<R>,
ws_manager: &Mutex<WebsocketManager>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?; let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db().resolve_environments( let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id, &unrendered_request.workspace_id,
@@ -91,7 +122,7 @@ pub async fn cmd_ws_send<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;
Ok(connection) Ok(connection.clone())
} }
#[command] #[command]
@@ -134,7 +165,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?; let resolved_settings =
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db().get_settings(); let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) = let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?; resolve_websocket_request(&window, &unrendered_request)?;
@@ -247,11 +279,18 @@ pub async fn cmd_ws_connect<R: Runtime>(
} }
} }
// Add cookies to WS HTTP Upgrade let mut cookie_jar = match (
if let Some(id) = cookie_jar_id { resolved_settings.send_cookies.value || resolved_settings.store_cookies.value,
let cookie_jar = app_handle.db().get_cookie_jar(&id)?; cookie_jar_id,
let store = CookieStore::from_cookies(cookie_jar.cookies); ) {
(true, Some(id)) => Some(app_handle.db().get_cookie_jar(id)?),
_ => None,
};
let cookie_store =
cookie_jar.as_ref().map(|jar| CookieStore::from_cookies(jar.cookies.clone()));
// Add cookies to WS HTTP Upgrade
if let (true, Some(store)) = (resolved_settings.send_cookies.value, cookie_store.as_ref()) {
// Convert WS URL -> HTTP URL because our cookie store matches based on // Convert WS URL -> HTTP URL because our cookie store matches based on
// Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests // Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
let http_url = convert_ws_url_to_http(&url); let http_url = convert_ws_url_to_http(&url);
@@ -289,8 +328,9 @@ pub async fn cmd_ws_connect<R: Runtime>(
url.as_str(), url.as_str(),
headers, headers,
receive_tx, receive_tx,
workspace.setting_validate_certificates, resolved_settings.validate_certificates.value,
client_cert, client_cert,
resolved_settings.request_message_size.value,
) )
.await .await
{ {
@@ -328,6 +368,23 @@ pub async fn cmd_ws_connect<R: Runtime>(
}) })
.collect::<Vec<HttpResponseHeader>>(); .collect::<Vec<HttpResponseHeader>>();
if let (true, Some(cookie_jar), Some(store)) =
(resolved_settings.store_cookies.value, cookie_jar.as_mut(), cookie_store.as_ref())
{
let set_cookie_headers = response
.headers()
.into_iter()
.filter(|(name, _)| name.as_str().eq_ignore_ascii_case("set-cookie"))
.filter_map(|(_, value)| value.to_str().ok().map(ToString::to_string))
.collect::<Vec<_>>();
if !set_cookie_headers.is_empty() {
store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers);
cookie_jar.cookies = store.get_all_cookies();
app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
}
}
let connection = app_handle.db().upsert_websocket_connection( let connection = app_handle.db().upsert_websocket_connection(
&WebsocketConnection { &WebsocketConnection {
state: WebsocketConnectionState::Connected, state: WebsocketConnectionState::Connected,
+148 -21
View File
@@ -1,45 +1,172 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, }; export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, export type Environment = {
/** model: "environment";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
name: string;
public: boolean;
parentModel: string;
parentId: string | null;
/**
* Variables defined in this environment scope. * Variables defined in this environment scope.
* Child environments override parent variables by name. * Child environments override parent variables by name.
*/ */
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; variables: Array<EnvironmentVariable>;
color: string | null;
sortPriority: number;
};
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, }; export type Folder = {
model: "folder";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
sortPriority: number;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, export type GrpcRequest = {
/** model: "grpc_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authenticationType: string | null;
authentication: Record<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
method: string | null;
name: string;
service: string | null;
sortPriority: number;
/**
* Server URL (http for plaintext or https for secure) * Server URL (http for plaintext or https for secure)
*/ */
url: string, }; url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, export type HttpRequest = {
/** model: "http_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries. * URL parameters used for both path placeholders (`:id`) and query string entries.
*/ */
urlParameters: Array<HttpUrlParameter>, }; urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
export type HttpUrlParameter = { enabled?: boolean, export type HttpUrlParameter = {
/** enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters * Other entries are appended as query parameters
*/ */
name: string, value: string, id?: string, }; name: string;
value: string;
id?: string;
};
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, export type InheritedIntSetting = { enabled?: boolean; value: number };
/**
export type SyncModel =
| ({ type: "workspace" } & Workspace)
| ({ type: "environment" } & Environment)
| ({ type: "folder" } & Folder)
| ({ type: "http_request" } & HttpRequest)
| ({ type: "grpc_request" } & GrpcRequest)
| ({ type: "websocket_request" } & WebsocketRequest);
export type WebsocketRequest = {
model: "websocket_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries. * URL parameters used for both path placeholders (`:id`) and query string entries.
*/ */
urlParameters: Array<HttpUrlParameter>, }; urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
};
+9 -3
View File
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
uri: &Uri, uri: &Uri,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> { ) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin( let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
); )
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin( .max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
let client_v1alpha =
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
); )
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha }) Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
} }
+80 -12
View File
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>, conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri, pub uri: Uri,
use_reflection: bool, use_reflection: bool,
max_message_size: usize,
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -97,7 +98,14 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> { ) -> Result<Response<DynamicMessage>> {
if self.use_reflection { if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert) reflect_types_for_message(
self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?; .await?;
} }
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
@@ -107,7 +115,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -132,6 +140,7 @@ impl GrpcConnection {
message, message,
metadata, metadata,
client_cert, client_cert,
self.max_message_size,
) )
.await?; .await?;
@@ -171,6 +180,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -183,8 +193,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -206,7 +223,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -237,6 +254,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -249,8 +267,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -272,7 +297,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -300,7 +325,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -312,6 +337,23 @@ impl GrpcConnection {
} }
} }
fn grpc_client(
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
uri: Uri,
max_message_size: usize,
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
tonic::client::Grpc::with_origin(conn, uri)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size)
}
fn message_size_limit(setting: i32) -> usize {
match setting.try_into() {
Ok(0) | Err(_) => usize::MAX,
Ok(limit) => limit,
}
}
/// Configuration for GrpcHandle to compile proto files /// Configuration for GrpcHandle to compile proto files
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcConfig { pub struct GrpcConfig {
@@ -348,6 +390,7 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> { ) -> Result<bool> {
let server_reflection = proto_files.is_empty(); let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files); let key = make_pool_key(id, uri, proto_files);
@@ -359,7 +402,14 @@ impl GrpcHandle {
let pool = if server_reflection { let pool = if server_reflection {
let full_uri = uri_from_str(uri)?; let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await fill_pool_from_reflection(
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
} else { } else {
fill_pool_from_files(&self.config, proto_files).await fill_pool_from_files(&self.config, proto_files).await
}?; }?;
@@ -376,11 +426,20 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) self.reflect(
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?; .await?;
} }
@@ -421,8 +480,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> { ) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty(); let use_reflection = proto_files.is_empty();
let max_message_size = message_size_limit(request_message_size);
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
self.reflect( self.reflect(
id, id,
@@ -431,6 +492,7 @@ impl GrpcHandle {
metadata, metadata,
validate_certificates, validate_certificates,
client_cert.clone(), client_cert.clone(),
request_message_size,
) )
.await?; .await?;
} }
@@ -440,7 +502,13 @@ impl GrpcHandle {
.clone(); .clone();
let uri = uri_from_str(uri)?; let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?; let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri }) Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
} }
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> { fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {

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