Compare commits

..

3 Commits

Author SHA1 Message Date
Gregory Schier ab785b18a4 Merge branch 'main' into codex-review/pr-457 2026-05-14 07:57:35 -07:00
Stijn Brouwers 947e3f2e97 Merge branch 'main' into feature/manual-cookies 2026-05-08 09:50:24 +02:00
Stijn BROUWERS 8b1f5e807f feat(cookies): Allow manually creating cookies 2026-05-07 19:56:49 +02:00
259 changed files with 3841 additions and 13335 deletions
@@ -19,12 +19,10 @@ Generate formatted markdown release notes for a Yaak tag.
- `gh pr view <PR_NUMBER> --json number,title,body,author,url` - `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details: 5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`) - Feedback URLs (`feedback.yaak.app`)
- Contributor GitHub handles from `author.login`
- Plugin install links or other notable context - Plugin install links or other notable context
6. Format notes using Yaak style: 6. Format notes using Yaak style:
- Changelog badge at top - Changelog badge at top
- Bulleted items with PR links where available - Bulleted items with PR links where available
- Contributor handles for external PRs
- Feedback links where available - Feedback links where available
- Full changelog compare link at bottom - Full changelog compare link at bottom
@@ -33,7 +31,6 @@ Generate formatted markdown release notes for a Yaak tag.
- Wrap final notes in a markdown code fence. - Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence. - Keep a blank line before and after the code fence.
- Output the markdown code block last. - Output the markdown code block last.
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
- Do not append `by @gschier` for PRs authored by `@gschier`. - Do not append `by @gschier` for PRs authored by `@gschier`.
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. - These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
-104
View File
@@ -1,104 +0,0 @@
---
name: yaak-changelog
description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
---
# Yaak Changelog
Use this skill to create Yaak changelogs in the correct place:
- Beta or draft prerelease changelogs live only on the GitHub release.
- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
## Workflow
1. Identify the target release.
- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
2. Parse release bullets.
- Treat each release-note bullet as one changelog entry.
- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
- Preserve the entry wording closely. Remove wrapping quotes from titles.
- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
3. For beta or draft prerelease changelogs, update the GitHub release.
- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
- Prefer concise bullets with PR links and feedback links when available.
- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
- Stop after verifying the GitHub release body. The website checks below do not apply.
4. For stable website changelogs, create or edit the release directory.
- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
- For a new release, use today's date for `YYYYMMDD`.
- For an existing release, keep the original directory date.
- Do not create changelog directories for beta releases.
5. Write `_release.yaml`.
- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
- Keep minor items as quick entries without `content`.
- Use `content` only when an entry needs its own markdown section.
```yaml
title: "What's New in 2026.1.0"
summary: "Brief overview of the most important additions and fixes"
draft: true
entries:
- title: "Request debugging"
category: feature
pr: "https://github.com/mountain-loop/yaak/pull/123"
feedback: "https://feedback.yaak.app/p/request-debugging"
content: "request-debugging.md"
- title: "Fix broken cookie clearing"
category: fix
pr: "https://github.com/mountain-loop/yaak/pull/124"
```
6. Expand major entries.
- Expand 3 to 6 major items when enough context exists.
- Create slugified markdown files and reference them with `content`.
- Read the related PR before writing expanded content.
- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
7. Handle images.
- Reuse screenshots from PRs when they exist.
- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
- If no real image is available, use a placeholder with real alt text and a caption.
8. Write `_intro.md`.
- Add a short overview paragraph at the top of the release.
- Focus on the major themes across the release instead of repeating every bullet.
9. Follow Yaak writing style.
- Be direct and factual. Avoid hype.
- State what changed and how to use it.
- Keep paragraphs short.
- Use backticks for code symbols, settings, and literal values.
- Use bold sparingly for the most important phrase in a section.
## File Rules
- Beta releases must not create or edit files in `src/content/changelog/`.
- Main files are `_release.yaml` and optional `_intro.md`.
- Expanded entry files are regular markdown files such as `request-debugging.md`.
- `entries[].content` must match an existing markdown filename in the same directory.
- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
## Checks
- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
- Ensure most entries include `pr` when the source release notes provide one.
- For stable releases, ensure every referenced `content` file exists.
- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
@@ -1,7 +0,0 @@
interface:
display_name: "Yaak Changelog"
short_description: "Generate Yaak changelog releases"
default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
policy:
allow_implicit_invocation: true
+3 -4
View File
@@ -4,14 +4,13 @@
## Submission ## Submission
- [ ] This PR is a bug fix. - [ ] This PR is a bug fix or small-scope improvement.
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it. - [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
Explicit permission feedback item (required if not a bug fix): Approved feedback item (required if not a bug fix or small-scope improvement):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
@@ -1,848 +0,0 @@
const fs = require("node:fs");
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const LABELS = {
inScope: {
name: "contribution: in scope",
color: "0E8A16",
description: "Community PR appears to be in scope for maintainer review.",
},
outOfScope: {
name: "contribution: out of scope",
color: "B60205",
description: "Community PR does not match Yaak's contribution policy.",
},
explicitPermission: {
name: "contribution: explicit permission",
color: "5319E7",
description:
"Community PR links feedback where @gschier explicitly allowed the work.",
},
missingTemplate: {
name: "contribution: missing template",
color: "D93F0B",
description:
"Community PR is missing enough of the pull request template to review.",
},
policyUnmet: {
name: "contribution: policy unmet",
color: "B60205",
description:
"Community PR does not currently satisfy the contribution policy.",
},
needsScopeReview: {
name: "contribution: needs scope review",
color: "FBCA04",
description:
"Community PR may be broader than Yaak's bug-fix contribution policy.",
},
};
const MANAGED_LABEL_NAMES = [
...new Set(Object.values(LABELS).map((label) => label.name)),
];
const CHECKBOXES = {
bugFix: "This PR is a bug fix.",
explicitPermission:
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
"I added screenshots or recordings for UI changes when reasonable.",
};
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeBody(body) {
return (body || "").replace(/\r\n/g, "\n");
}
function stripComments(value) {
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
}
function getSection(body, heading) {
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
const match = pattern.exec(body);
if (match == null) {
return null;
}
const rest = body.slice(match.index + match[0].length);
const nextHeadingIndex = rest.search(/^##\s+/m);
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
}
function hasMeaningfulText(value) {
return stripComments(value || "").length > 0;
}
function normalizeCheckboxLabel(label) {
return label
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim();
}
function checkboxState(body, label) {
const expectedLabel = normalizeCheckboxLabel(label);
for (const line of body.split("\n")) {
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
if (match == null) {
continue;
}
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
return match[1].toLowerCase() === "x";
}
}
return null;
}
function findFeedbackUrl(body) {
return (
body.match(
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
)?.[0] ?? null
);
}
function getLabelNames(pr) {
return new Set((pr.labels || []).map((label) => label.name));
}
function analyzePullRequest(pr) {
const body = normalizeBody(pr.body);
const labelNames = getLabelNames(pr);
const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [
key,
checkboxState(body, label),
]),
);
const sectionCount = ["Summary", "Submission", "Related"].filter(
(heading) => getSection(body, heading) != null,
).length;
const checkboxCount = Object.values(states).filter(
(state) => state != null,
).length;
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
const blockers = [];
const totalChangedLines =
Number(pr.additions || 0) + Number(pr.deletions || 0);
const changedFiles = Number(pr.changed_files || 0);
const largeDiff =
changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (labelNames.has(LABELS.outOfScope.name)) {
return {
blockers: [
{
label: LABELS.outOfScope.name,
message: "Marked out of scope by maintainer label.",
},
],
changedFiles,
desiredLabels: [LABELS.outOfScope.name],
largeDiff,
status: "out_of_scope",
templateUsed,
totalChangedLines,
};
}
if (labelNames.has(LABELS.inScope.name)) {
return {
blockers: [],
changedFiles,
desiredLabels: [LABELS.inScope.name],
largeDiff,
status: "in_scope",
templateUsed,
totalChangedLines,
};
}
if (!templateUsed) {
blockers.push({
label: LABELS.missingTemplate.name,
message:
"Update the PR description with the repository pull request template.",
});
} else {
const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body);
const bugFix = states.bugFix === true;
const explicitPermission = states.explicitPermission === true;
if (!hasSummary) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Add a short summary describing the bug fix or permitted change.",
});
}
if (bugFix && explicitPermission) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
});
} else if (!bugFix && !explicitPermission) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
});
} else if (explicitPermission && feedbackUrl == null) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
});
}
if (states.readContributing !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
});
}
if (states.testedLocally !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that the change was tested locally.",
});
}
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.",
});
}
if (states.screenshotsAdded !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
});
}
}
const desiredLabels = new Set();
if (blockers.length === 0) {
desiredLabels.add(
largeDiff
? LABELS.needsScopeReview.name
: states.explicitPermission
? LABELS.explicitPermission.name
: LABELS.inScope.name,
);
} else if (
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
) {
desiredLabels.add(LABELS.missingTemplate.name);
} else {
desiredLabels.add(LABELS.policyUnmet.name);
}
return {
blockers,
changedFiles,
desiredLabels: [...desiredLabels],
largeDiff,
status: blockers.length === 0 ? "in_scope" : "blocked",
templateUsed,
totalChangedLines,
};
}
function buildBlockingComment(analysis) {
const lines = [
COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
"",
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
"",
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
];
if (!analysis.templateUsed) {
lines.push(
"",
"You can copy this template into the PR description and keep any existing context that is still useful.",
"",
"<details>",
"<summary>PR description template</summary>",
"",
"```md",
getPullRequestTemplate(),
"```",
"",
"</details>",
);
}
if (analysis.largeDiff) {
lines.push(
"",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
);
}
return lines.join("\n");
}
function getPullRequestTemplate() {
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
}
function buildInScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
"",
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
].join("\n");
}
function buildOutOfScopeComment() {
return [
COMMENT_MARKER,
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
"",
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
"",
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
].join("\n");
}
function buildPolicyComment(analysis) {
if (analysis.status === "out_of_scope") {
return buildOutOfScopeComment();
}
if (analysis.blockers.length > 0) {
return buildBlockingComment(analysis);
}
return buildInScopeComment();
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function truncateTitle(title) {
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
}
function escapeTableText(value) {
return escapeHtml(value).replace(/\n/g, "<br>");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
const comment =
analysis == null
? "None"
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
const summary = {
blocked: analysis?.blockers.length > 0,
comment,
details: "None",
labels:
analysis?.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "None",
number: pr.number,
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
status: "In scope",
title: escapeHtml(truncateTitle(pr.title)),
};
if (skipped) {
return {
...summary,
blocked: false,
comment: "None",
details: escapeHtml(skipReason),
labels: "None",
status: "Skipped",
};
}
if (summary.blocked) {
return {
...summary,
comment: escapeTableText(summary.comment),
details: escapeHtml(
analysis.blockers.map((blocker) => blocker.message).join("; "),
),
labels: escapeHtml(summary.labels),
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
};
}
return {
...summary,
comment: escapeTableText(summary.comment),
labels: escapeHtml(summary.labels),
};
}
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
}
async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true;
}
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
return true;
}
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: pr.user.login,
});
return MAINTAINER_PERMISSIONS.has(response.data.permission);
} catch (error) {
if (error.status === 404) {
return false;
}
throw error;
}
}
async function ensureManagedLabels({ github, owner, repo }) {
for (const label of Object.values(LABELS)) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label.name,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label.name,
color: label.color,
description: label.description,
});
}
}
}
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
const desired = new Set(desiredLabels);
await ensureManagedLabels({ github, owner, repo });
for (const labelName of MANAGED_LABEL_NAMES) {
if (desired.has(labelName)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (desired.size > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: [...desired],
});
}
}
async function findPolicyComment({ github, owner, repo, issueNumber }) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return comments.find(
(comment) =>
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
);
}
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return;
}
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
}
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existingComment.id,
});
}
async function requestMaintainerReview({ github, owner, repo, pr }) {
if (pr.user.login === REVIEWER_LOGIN) {
return;
}
try {
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pr.number,
reviewers: [REVIEWER_LOGIN],
});
} catch (error) {
if (error.status === 422) {
return;
}
throw error;
}
}
async function checkPullRequest({
github,
core,
owner,
repo,
pullNumber,
dryRun,
skipCreatedBefore,
}) {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const pr = response.data;
const issueNumber = pr.number;
if (
skipCreatedBefore != null &&
wasCreatedBefore(pr.created_at, skipCreatedBefore)
) {
core.notice(
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
}),
skipped: true,
};
}
if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: "draft",
}),
skipped: true,
};
}
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
core.notice(
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
);
if (!dryRun) {
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
await deletePolicyComment({ github, owner, repo, issueNumber });
}
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `maintainer @${pr.user.login}`,
}),
skipped: true,
};
}
const analysis = analyzePullRequest(pr);
if (dryRun) {
const summary = summarizeResult({ pr, analysis });
core.notice(
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
);
return {
blocked: analysis.blockers.length > 0,
number: pr.number,
summary,
skipped: false,
};
}
await syncLabels({
github,
owner,
repo,
issueNumber,
desiredLabels: analysis.desiredLabels,
});
if (analysis.blockers.length > 0) {
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
await requestMaintainerReview({ github, owner, repo, pr });
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
async function listOpenPullRequests({ github, owner, repo }) {
return github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
}
function getManualPullRequestNumbers({ context, core }) {
const value = String(context.payload.inputs?.pr || "all").trim();
if (value.toLowerCase() === "all") {
return null;
}
const pullNumber = Number(value);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
core.setFailed('The "pr" input must be "all" or a positive PR number.');
return [];
}
return [pullNumber];
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRunInput = context.payload.inputs?.dry_run;
const dryRun =
context.eventName === "workflow_dispatch" &&
dryRunInput !== false &&
dryRunInput !== "false";
const skipCreatedBefore =
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
let pullNumbers;
if (payloadPr != null) {
pullNumbers = [payloadPr.number];
} else {
pullNumbers = getManualPullRequestNumbers({ context, core });
}
if (pullNumbers?.length === 0) {
return;
}
const pullRequests =
pullNumbers == null
? await listOpenPullRequests({ github, owner, repo })
: pullNumbers.map((number) => ({ number }));
const results = [];
if (dryRun) {
core.notice(
`Running contribution policy in dry-run mode for ${
pullNumbers == null
? "all open PRs"
: pullNumbers.map((number) => `#${number}`).join(", ")
}.`,
);
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
skipCreatedBefore,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Title", header: true },
{ data: "Status", header: true },
{ data: "Labels", header: true },
{ data: "Details", header: true },
{ data: "Comment", header: true },
],
...results.map((result) => [
result.summary.prLink,
result.summary.title,
result.summary.status,
result.summary.labels,
result.summary.details,
result.summary.comment,
]),
])
.write();
const blockedPullRequests = results.filter((result) => result.blocked);
if (blockedPullRequests.length > 0) {
if (dryRun) {
core.warning(
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
return;
}
core.setFailed(
`Contribution policy failed for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
}
}
module.exports = {
analyzePullRequest,
run,
};
-47
View File
@@ -1,47 +0,0 @@
name: Contribution Policy
on:
workflow_dispatch:
inputs:
pr:
description: PR number or all
required: true
default: all
type: string
dry_run:
description: Dry run
required: true
default: true
type: boolean
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
- labeled
- unlabeled
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
name: Check contribution policy
runs-on: ubuntu-latest
steps:
- name: Checkout policy script
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha || github.ref }}
fetch-depth: 1
- name: Check contribution policy
uses: actions/github-script@v7
with:
script: |
const { run } = require("./.github/scripts/check-contribution-policy.js");
await run({ github, context, core });
+6 -9
View File
@@ -1,12 +1,7 @@
name: Update Flathub name: Update Flathub
on: on:
workflow_dispatch: release:
inputs: types: [published]
tag:
description: Release tag to publish to Flathub
required: true
type: string
permissions: permissions:
contents: read contents: read
@@ -15,6 +10,8 @@ jobs:
update-flathub: update-flathub:
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps: steps:
- name: Checkout app repo - name: Checkout app repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -42,7 +39,7 @@ jobs:
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh - name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub - name: Commit and push to Flathub
working-directory: flathub-repo working-directory: flathub-repo
@@ -51,5 +48,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0 git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ inputs.tag }}" git commit -m "Update to ${{ github.event.release.tag_name }}"
git push git push
+3 -11
View File
@@ -24,12 +24,12 @@ jobs:
os: "macos" os: "macos"
targets: "x86_64-apple-darwin" targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: "--no-default-features --features updater,license,cef" args: ""
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: "--no-default-features --features updater,license,cef" args: ""
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" os: "ubuntu"
targets: "" targets: ""
@@ -66,18 +66,11 @@ jobs:
shared-key: ci shared-key: ci
cache-on-failure: true cache-on-failure: true
- name: Cache CEF (Linux only)
if: matrix.os == 'ubuntu'
uses: actions/cache@v4
with:
path: ~/.cache/tauri-cef
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
- name: install dependencies (Linux only) - name: install dependencies (Linux only)
if: matrix.os == 'ubuntu' if: matrix.os == 'ubuntu'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Protoc for plugin-runtime - name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3 uses: arduino/setup-protoc@v3
@@ -157,7 +150,6 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with: with:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__" tagName: "v__VERSION__"
releaseName: "Release __VERSION__" releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
+2 -1
View File
@@ -3,12 +3,13 @@
Yaak accepts community pull requests for: Yaak accepts community pull requests for:
- Bug fixes - Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes ## Approval for Non-Bugfix Changes
If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it. If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
## Development Setup ## Development Setup
Generated
+84 -750
View File
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -47,11 +47,7 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
sha2 = "0.10.9" sha2 = "0.10.9"
tauri = { version = "2.11.1", default-features = false, features = [ tauri = "2.11.1"
"common-controls-v6",
"compression",
"dynamic-acl",
] }
tauri-plugin = "2.6.1" tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-shell = "2.3.5" tauri-plugin-shell = "2.3.5"
@@ -93,7 +89,3 @@ yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release] [profile.release]
strip = false strip = false
[patch.crates-io]
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
+2 -2
View File
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]
> Community PRs are currently limited to bug fixes. > Community PRs are currently limited to bug fixes and small-scope improvements.
> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it. > If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources ## Useful Resources
@@ -10,7 +10,7 @@ export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
id: "folder-settings", id: "folder-settings",
title: null, title: null,
size: "lg", size: "lg",
className: "h-200", className: "h-[50rem]",
noPadding: true, noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />, render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
}); });
@@ -1,10 +1,19 @@
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;
WorkspaceSettingsDialog.show(workspaceId, tab); showDialog({
id: "workspace-settings",
size: "md",
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
} }
@@ -38,7 +38,7 @@ export function BinaryFileEditor({
<VStack space={2}> <VStack space={2}>
<SelectFile onChange={handleChange} filePath={filePath} /> <SelectFile onChange={handleChange} filePath={filePath} />
{filePath != null && mimeType !== contentType && !ignoreContentType.value && ( {filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 py-5!"> <Banner className="mt-3 !py-5">
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div>Set Content-Type header</div> <div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request? <InlineCode>{mimeType}</InlineCode> for current request?
@@ -4,7 +4,6 @@ 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";
@@ -90,8 +89,6 @@ 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"
@@ -108,7 +105,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
rightSlot={ rightSlot={
<IconButton <IconButton
size="xs" size="xs"
className="mr-0.5 h-auto! my-0.5" className="mr-0.5 !h-auto my-0.5"
icon="folder" icon="folder"
title="Browse" title="Browse"
onClick={handleSelectDirectory} onClick={handleSelectDirectory}
@@ -11,7 +11,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined }; const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames( const finalClassName = classNames(
className, className,
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0", "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
); );
if (onClick) { if (onClick) {
@@ -15,7 +15,6 @@ 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";
@@ -37,6 +36,7 @@ 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,12 +99,6 @@ 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",
@@ -133,9 +127,13 @@ 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 () => {
CookieDialog.show(activeCookieJar?.id ?? null); showDialog({
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
}, },
}, },
{ {
@@ -439,7 +437,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
name="command" name="command"
label="Command" label="Command"
placeholder="Search or type a command" placeholder="Search or type a command"
className="font-sans text-base!" className="font-sans !text-base"
defaultValue={command} defaultValue={command}
onChange={handleSetCommand} onChange={handleSetCommand}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
@@ -448,7 +446,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1"> <div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
{filteredGroups.map((g) => ( {filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full"> <div key={g.key} className="mb-1.5 w-full">
<Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center"> <Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
{g.label} {g.label}
</Heading> </Heading>
{g.items.map((v) => ( {g.items.map((v) => (
@@ -491,7 +489,7 @@ function CommandPaletteItem({
color="custom" color="custom"
justify="start" justify="start"
className={classNames( className={classNames(
"w-full h-sm flex items-center rounded-sm px-1.5", "w-full h-sm flex items-center rounded px-1.5",
"hover:text-text", "hover:text-text",
active && "bg-surface-highlight", active && "bg-surface-highlight",
!active && "text-text-subtle", !active && "text-text-subtle",
@@ -1,130 +0,0 @@
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 && !snoozeStartedRef.current)) {
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 === "personal_use";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return false;
}
}
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;
}
+158 -699
View File
@@ -1,731 +1,190 @@
import type { Cookie } from "@yaakapp-internal/models"; import type { Cookie, CookieDomain, CookieJar } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models"; import { cookieJarsAtom, patchModelById } 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 { import { showPromptForm } from "../lib/prompt-form";
Icon, import { Banner, InlineCode } from "@yaakapp-internal/ui";
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;
} }
async function showAddCookieForm(cookieJarId: string): Promise<void> {
const result = await showPromptForm({
id: "add-cookie",
title: "Add Cookie",
size: "md",
inputs: [
{
name: "cookie_pairs",
label: "Cookie Attributes",
type: "key_value",
description:
"Add key-value pairs for the cookie. These will be combined into the cookie string.",
},
{
name: "domain_value",
label: "Domain",
type: "text",
placeholder: "example.com",
},
{
name: "hostOnly",
label: "Host Only",
type: "checkbox",
defaultValue: "true",
description:
"If enabled, cookie is restricted to the exact host. Otherwise, it applies to the domain and its subdomains.",
},
{
name: "path",
label: "Path",
type: "text",
placeholder: "/",
defaultValue: "/",
},
{
name: "secure",
label: "Secure",
type: "checkbox",
defaultValue: "true",
description: "If enabled, cookie will only be sent over HTTPS connections.",
},
],
});
if (result == null) return;
// Parse the form results
const cookie_pairs_raw = result.cookie_pairs;
const domain_value = (result.domain_value as string) ?? "";
const path = (result.path as string) ?? "/";
const hostOnly = (result.hostOnly as string) === "true";
const secure = (result.secure as string) === "true";
// Convert key-value pairs to raw_cookie string format: key1=value1;key2=value2
// Parse cookie_pairs - it comes as a JSON string from the key_value input
let parsedPairs: Array<{ name: string; value: string }> = [];
try {
// Handle null, undefined, or string value
const pairsStr =
typeof cookie_pairs_raw === "string"
? cookie_pairs_raw
: cookie_pairs_raw != null
? JSON.stringify(cookie_pairs_raw)
: "[]";
if (pairsStr && pairsStr !== "") {
parsedPairs = JSON.parse(pairsStr);
}
} catch {
parsedPairs = [];
}
const validPairs = parsedPairs.filter((p) => p?.name?.trim());
// Ensure at least one valid pair exists
if (validPairs.length === 0) {
console.log("No valid cookie pairs provided");
return;
}
const raw_cookie = validPairs.map((p) => `${p.name}=${p.value}`).join(";");
const domain: CookieDomain = hostOnly
? { HostOnly: domain_value ?? "" }
: { Suffix: domain_value ?? "" };
// Build the new cookie with explicit tuple type for path
const newCookie: Cookie = {
raw_cookie,
domain,
expires: "SessionEnd",
path: [path, secure] as [string, boolean],
};
try {
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJarId, (prev) => ({
...prev,
cookies: [...prev.cookies, newCookie],
}));
} catch (error) {
console.error("Failed to add cookie:", error);
throw error;
}
}
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>;
} }
return ( const onAddCookie = () => showAddCookieForm(cookieJar.id);
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<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 ( let tableBody;
<TableRow if (cookieJar.cookies.length === 0) {
key={key} tableBody = (
className={classNames( <tr>
"group/tr cursor-default", <td colSpan={3}>
isSelected && "[&_td]:bg-surface-highlight", <Banner>
!isSelected && "hover:[&_td]:bg-surface-hover", Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode>{" "}
)} header
onClick={() => { </Banner>
setSelectedCookieKey(key); </td>
setEditingCookieKey(null); </tr>
setDraftCookie(null); );
setDraftExpiresInput(""); // );
}} } else {
> tableBody = cookieJar?.cookies.map((c: Cookie) => (
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}> <tr key={JSON.stringify(c)}>
{c.name} <td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
</TableCell> {cookieDomain(c)}
<TruncatedWideTableCell className="min-w-40"> </td>
{c.value} <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">
</TruncatedWideTableCell> {c.raw_cookie}
<TableCell>{cookieDomain(c)}</TableCell> </td>
<TableCell>{c.path}</TableCell> <td className="max-w-0 w-10">
<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="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors" className="ml-auto"
onClick={(event) => { onClick={async () =>
event.stopPropagation(); await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJar.id, (prev) => ({
if (isSelected) { ...prev,
setSelectedCookieKey(null); cookies: prev.cookies.filter((c2: Cookie) => c2 !== c),
} }))
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>
</div> </tr>
); ));
};
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 ( return (
<div style={style} className={className}> <div className="pb-2">
{children} <table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
</div> <thead>
); <tr>
} <th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
CookieDialog.show = (cookieJarId: string | null) => { <th className="py-2 pl-4 w-10">
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId); <IconButton
if (cookieJar == null) { icon="plus"
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" size="xs"
className="w-full" iconSize="sm"
options={[ title="Add Cookie"
{ label: "n/a", value: "" }, className="ml-auto"
{ label: "Lax", value: "Lax" }, onClick={onAddCookie}
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/> />
</CookieKeyValueRow> </th>
</KeyValueRows> </tr>
</thead>
<tbody className="divide-y divide-surface-highlight">{tableBody}</tbody>
</table>
</div> </div>
); );
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-28", 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-20 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-hidden",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
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,6 +4,7 @@ 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";
@@ -35,7 +36,12 @@ export const CookieDropdown = memo(function CookieDropdown() {
leftSlot: <Icon icon="cookie" />, leftSlot: <Icon icon="cookie" />,
onSelect: () => { onSelect: () => {
if (activeCookieJar == null) return; if (activeCookieJar == null) return;
CookieDialog.show(activeCookieJar.id); showDialog({
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
}, },
}, },
{ {
@@ -75,7 +75,7 @@ export function DnsOverridesEditor({ workspace }: Props) {
<VStack space={3} className="pb-3"> <VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm"> <div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{" "} Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but <code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
only for requests made from this workspace. only for requests made from this workspace.
</div> </div>
+2 -2
View File
@@ -23,8 +23,8 @@ export const DropMarker = memo(
<div <div
className={classNames( className={classNames(
"absolute bg-primary rounded-full", "absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]", orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]", orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
)} )}
/> />
</div> </div>
+5 -5
View File
@@ -204,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
summary={input.label} summary={input.label}
className={classNames("mb-auto!", disabled && "opacity-disabled")} className={classNames("!mb-auto", disabled && "opacity-disabled")}
> >
<div className="mt-3"> <div className="mt-3">
<FormInputsStack <FormInputsStack
@@ -300,7 +300,7 @@ function TextArg({
onChange, onChange,
name: arg.name, name: arg.name,
multiLine: arg.multiLine, multiLine: arg.multiLine,
className: arg.multiLine ? "min-h-16" : undefined, className: arg.multiLine ? "min-h-[4rem]" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value, defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional, required: !arg.optional,
disabled: arg.disabled, disabled: arg.disabled,
@@ -359,7 +359,7 @@ function EditorArg({
className={classNames( className={classNames(
"border border-border rounded-md overflow-hidden px-2 py-1", "border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus", "focus-within:border-border-focus",
!arg.rows && "max-h-40", // So it doesn't take up too much space !arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
)} )}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined} style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
> >
@@ -372,7 +372,7 @@ function EditorArg({
onChange={onChange} onChange={onChange}
hideGutter hideGutter
heightMode="auto" heightMode="auto"
className="min-h-12" className="min-h-[3rem]"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value} defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined} placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
@@ -392,7 +392,7 @@ function EditorArg({
id: "id", id: "id",
size: "full", size: "full",
title: arg.readOnly ? "View Value" : "Edit Value", title: arg.readOnly ? "View Value" : "Edit Value",
className: "max-w-200! max-h-240!", className: "!max-w-[50rem] !max-h-[60rem]",
description: arg.label && ( description: arg.label && (
<Label <Label
htmlFor={id} htmlFor={id}
@@ -4,12 +4,11 @@ import type { ReactNode } from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
wrapperClassName?: string;
} }
export function EmptyStateText({ children, className, wrapperClassName }: Props) { export function EmptyStateText({ children, className }: Props) {
return ( return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}> <div className="w-full h-full pb-2">
<div <div
className={classNames( className={classNames(
className, className,
@@ -62,7 +62,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text px-2! truncate", "text !px-2 truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic", !activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)} )}
// If no environments, the button simply opens the dialog. // If no environments, the button simply opens the dialog.
@@ -57,7 +57,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
defaultRatio={0.75} defaultRatio={0.75}
layout="horizontal" layout="horizontal"
className="gap-0" className="gap-0"
resizeHandleClassName="-translate-x-px" resizeHandleClassName="-translate-x-[1px]"
firstSlot={() => ( firstSlot={() => (
<EnvironmentEditDialogSidebar <EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null} selectedEnvironmentId={selectedEnvironment?.id ?? null}
@@ -8,7 +8,6 @@ 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";
@@ -86,10 +85,8 @@ 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="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg"> <div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
<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>
@@ -140,9 +137,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
+2 -2
View File
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
return ( return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden"> <div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0"> <code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url} {request.method} {request.url}
</code> </code>
{latestResponse ? ( {latestResponse ? (
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
className={classNames( className={classNames(
"cursor-default select-none", "cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
"font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full", "font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
)} )}
> >
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />} {latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
@@ -21,7 +21,6 @@ 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;
@@ -30,7 +29,6 @@ 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";
@@ -38,7 +36,6 @@ 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) {
@@ -54,7 +51,6 @@ 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 [];
@@ -64,11 +60,6 @@ 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,
{ {
@@ -77,19 +68,19 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null, rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
}, },
]; ];
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]); }, [authTab, folder, headersTab, numVars]);
if (folder == null) return null; if (folder == null) return null;
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl"> <div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" /> <Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1"> <div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => ( {breadcrumbs.map((item, index) => (
<Fragment key={item.id}> <Fragment key={item.id}>
{index > 0 && ( {index > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)} )}
<span className="text-text-subtle truncate min-w-0" title={item.name}> <span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name} {item.name}
@@ -97,7 +88,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
</Fragment> </Fragment>
))} ))}
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)} )}
<span className="whitespace-nowrap" title={folder.name}> <span className="whitespace-nowrap" title={folder.name}>
{folder.name} {folder.name}
@@ -149,7 +140,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id} {folder.id}
<CopyIconButton <CopyIconButton
className="opacity-70 text-primary!" className="opacity-70 !text-primary"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy folder ID" title="Copy folder ID"
@@ -168,9 +159,6 @@ 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>
+1 -1
View File
@@ -126,7 +126,7 @@ export function GrpcEditor({
const actions = useMemo( const actions = useMemo(
() => [ () => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}> <div key="reflection" className={classNames(services == null && "!opacity-100")}>
<Button <Button
size="xs" size="xs"
color={ color={
@@ -20,7 +20,6 @@ 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 {
@@ -48,7 +47,6 @@ 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({
@@ -68,7 +66,6 @@ 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);
@@ -131,18 +128,13 @@ 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, numSettingsOverrides], [activeRequest.description, authTab, metadataTab],
); );
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
@@ -162,7 +154,7 @@ export function GrpcRequestPane({
className={classNames( className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5", "grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0", paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "grid-cols-1!", paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
)} )}
> >
<UrlBar <UrlBar
@@ -201,7 +193,7 @@ export function GrpcRequestPane({
rightSlot={<Icon size="sm" icon="chevron_down" />} rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null} disabled={isStreaming || services == null}
className={classNames( className={classNames(
"font-mono text-editor min-w-20 ring-0!", "font-mono text-editor min-w-[5rem] !ring-0",
paneWidth < 400 && "flex-1", paneWidth < 400 && "flex-1",
)} )}
> >
@@ -259,7 +251,7 @@ export function GrpcRequestPane({
<Tabs <Tabs
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 mb-1.5!" tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs" storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id} activeTabKey={activeRequest.id}
> >
@@ -286,9 +278,6 @@ 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
@@ -296,7 +285,7 @@ export function GrpcRequestPane({
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!" className="font-sans !text-xl !px-0"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
@@ -10,17 +10,14 @@ 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";
@@ -38,8 +35,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -51,8 +47,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for{" "} Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -61,20 +56,11 @@ 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-3"> <EmptyStateText className="flex-col gap-1">
<div className="not-italic flex flex-col items-center gap-3 text-center"> <p>
<p className="max-w-md text-sm text-text-subtle"> Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
Choose an auth method to apply it to all requests in{" "}
<strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p> </p>
<AuthenticationTypeDropdown model={model} /> <Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText> </EmptyStateText>
); );
} }
@@ -97,8 +83,7 @@ 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") if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -118,8 +103,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled === false || model.authentication.disabled == null
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -156,7 +140,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
title="Authentication Actions" title="Authentication Actions"
icon="settings" icon="settings"
size="xs" size="xs"
className="text-secondary!" className="!text-secondary"
/> />
</Dropdown> </Dropdown>
)} )}
@@ -167,9 +151,7 @@ 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) => onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -187,33 +169,6 @@ 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,
@@ -243,11 +198,7 @@ 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 {rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -57,7 +57,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
<GraphQLDocsExplorer <GraphQLDocsExplorer
requestId={activeRequest.id} requestId={activeRequest.id}
schema={graphQLSchema} schema={graphQLSchema}
className={classNames(orientation === "horizontal" && "ml-0!")} className={classNames(orientation === "horizontal" && "!ml-0")}
style={style} style={style}
/> />
)} )}
@@ -19,7 +19,6 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -52,7 +51,6 @@ 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";
@@ -71,7 +69,6 @@ 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";
@@ -95,7 +92,6 @@ 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(
@@ -132,7 +128,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url); const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -236,11 +234,6 @@ 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",
@@ -253,7 +246,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
handleContentTypeChange, handleContentTypeChange,
headersTab, headersTab,
numParams, numParams,
numSettingsOverrides,
urlParameterPairs.length, urlParameterPairs.length,
], ],
); );
@@ -346,7 +338,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onUrlChange={handleUrlChange} onUrlChange={handleUrlChange}
leftSlot={ leftSlot={
<div className="py-0.5"> <div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" /> <RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
</div> </div>
} }
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
@@ -380,9 +372,6 @@ 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 ? (
@@ -456,7 +445,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
hideLabel hideLabel
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!" className="font-sans !text-xl !px-0"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
@@ -4,12 +4,10 @@ import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react"; import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react"; import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType"; import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
@@ -80,8 +78,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.state === "closed" && redirectDropWarning != null; activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -97,22 +93,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
? [] ? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
], ],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
}, },
}, },
{ {
@@ -155,18 +135,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
], ],
[ [
activeResponse?.headers, activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength, activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length, activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent, cookieCounts.sent,
cookieCounts.received, cookieCounts.received,
copyResponse.mutate,
mimeType, mimeType,
responseEvents.data?.length, responseEvents.data?.length,
saveResponse.mutate,
setViewMode, setViewMode,
viewMode, viewMode,
timelineViewMode, timelineViewMode,
@@ -193,7 +167,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack
className={classNames( className={classNames(
"text-text-subtle w-full shrink-0", "text-text-subtle w-full flex-shrink-0",
// Remove a bit of space because the tabs have lots too // Remove a bit of space because the tabs have lots too
"-mb-1.5", "-mb-1.5",
)} )}
@@ -206,7 +180,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)} )}
> >
<HStack space={2} className="w-full shrink-0"> <HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />} {activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} /> <HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span> <span>&bull;</span>
@@ -220,7 +194,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
{shouldShowRedirectDropWarning ? ( {shouldShowRedirectDropWarning ? (
<Tooltip <Tooltip
tabIndex={0} tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden" className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
content={ content={
<VStack alignItems="start" space={1} className="text-xs"> <VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning"> <span className="font-medium text-warning">
@@ -249,7 +223,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<span className="inline-flex min-w-0"> <span className="inline-flex min-w-0">
<PillButton <PillButton
color="warning" color="warning"
className="font-sans text-sm shrink! max-w-full" className="font-sans text-sm !flex-shrink max-w-full"
innerClassName="flex items-center" innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />} leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
> >
@@ -262,7 +236,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : ( ) : (
<span /> <span />
)} )}
<div className="justify-self-end shrink-0"> <div className="justify-self-end flex-shrink-0">
<RecentHttpResponsesDropdown <RecentHttpResponsesDropdown
responses={responses} responses={responses}
activeResponse={activeResponse} activeResponse={activeResponse}
@@ -275,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="overflow-hidden flex flex-col min-h-0"> <div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && ( {activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 shrink-0"> <Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
{activeResponse.error} {activeResponse.error}
</Banner> </Banner>
)} )}
@@ -1,15 +1,10 @@
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";
@@ -100,7 +95,6 @@ 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[] = [
{ {
@@ -217,9 +211,6 @@ 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>
); );
} }
@@ -324,44 +315,6 @@ 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"];
@@ -372,12 +325,11 @@ 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}${sourceModel == null ? "" : ` (${sourceModel})`}`, summary: `${event.name} = ${event.value}`,
}; };
case "info": case "info":
return { return {
@@ -1,7 +1,6 @@
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";
@@ -15,8 +14,6 @@ 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>
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const actions = useMemo<EditorProps["actions"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
showBanner && ( showBanner && (
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs"> <Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0"> <p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span> <span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" /> <Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p> </p>
</Banner> </Banner>
), ),
<div key="settings" className="opacity-100! shadow!"> <div key="settings" className="!opacity-100 !shadow">
<Dropdown <Dropdown
onOpen={handleDropdownOpen} onOpen={handleDropdownOpen}
items={ items={
+1 -1
View File
@@ -59,7 +59,7 @@ function getDetail(
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`, label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
}, },
{ {
label: <div className="min-w-48">Renew License</div>, label: <div className="min-w-[12rem]">Renew License</div>,
leftSlot: <Icon icon="refresh" />, leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />, rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null, hidden: data.data.changesUrl == null,
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor <Editor
hideGutter hideGutter
wrapLines wrapLines
className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")} className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")}
language="markdown" language="markdown"
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onChange} onChange={onChange}
@@ -46,7 +46,7 @@ export function MarkdownEditor({
defaultValue.length === 0 ? ( defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p> <p className="text-text-subtlest">No description</p>
) : ( ) : (
<div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto"> <div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown> <Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
</div> </div>
); );
@@ -1,634 +0,0 @@
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
);
}
@@ -66,7 +66,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
<Button <Button
size="xs" size="xs"
color="secondary" color="secondary"
className="mr-auto min-w-20" className="mr-auto min-w-[5rem]"
onClick={async () => { onClick={async () => {
await router.navigate({ await router.navigate({
to: "/workspaces/$workspaceId", to: "/workspaces/$workspaceId",
+5 -7
View File
@@ -1,5 +1,3 @@
@reference "../main.css";
.prose { .prose {
@apply text-text; @apply text-text;
@@ -100,7 +98,7 @@
@apply text-notice hover:underline; @apply text-notice hover:underline;
* { * {
@apply text-notice!; @apply text-notice !important;
} }
} }
@@ -115,12 +113,12 @@
ol code, ol code,
ul code { ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap; @apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded-sm not-italic; @apply px-1.5 py-0.5 rounded not-italic;
@apply select-text; @apply select-text;
} }
pre { pre {
@apply bg-surface-highlight! text-text!; @apply bg-surface-highlight text-text !important;
@apply px-4 py-3 rounded-md; @apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre; @apply overflow-auto whitespace-pre;
@apply text-editor font-mono; @apply text-editor font-mono;
@@ -132,7 +130,7 @@
.banner { .banner {
@apply border border-dashed; @apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base; @apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;
&::before { &::before {
@apply block font-bold mb-1; @apply block font-bold mb-1;
@@ -163,7 +161,7 @@
} }
blockquote { blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg; @apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;
p { p {
@apply m-0; @apply m-0;
@@ -1,17 +1,10 @@
import type { GrpcConnection } from "@yaakapp-internal/models"; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { import { formatDistanceToNowStrict } from "date-fns";
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -27,63 +20,6 @@ export function RecentGrpcConnectionsDropdown({
}: Props) { }: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -100,7 +36,16 @@ export function RecentGrpcConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connectionHistoryItems, ...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -1,21 +1,13 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue"; import { useSaveResponse } from "../hooks/useSaveResponse";
import { DismissibleBanner } from "./core/DismissibleBanner"; import { pluralize } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -30,93 +22,32 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a"; const latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = []; const saveResponse = useSaveResponse(activeResponse);
let lastHistoryGroup: string | null = null; const copyResponse = useCopyHttpResponse(activeResponse);
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return ( return (
<Dropdown <Dropdown
items={[ items={[
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{ {
label: "Delete", label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse), onSelect: () => deleteModel(activeResponse),
}, },
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{ {
label: "Unpin Response", label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id), onSelect: () => onPinnedResponseId(activeResponse.id),
@@ -124,25 +55,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: "separator", label: "History" },
{ {
type: "content", label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`,
hidden: dismissedMovedActions === true, onSelect: deleteAllResponses.mutate,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{ type: "separator" },
...responses.map((r: HttpResponse) => ({
label: ( label: (
<DismissibleBanner <HStack space={2}>
id={movedActionsBannerId} <HttpStatusTag short className="text-xs" response={r} />
color="info" <span className="text-text-subtle">&rarr;</span>{" "}
size="xs" <span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span>
className="max-w-72" </HStack>
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
), ),
}, leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
{ onSelect: () => onPinnedResponseId(r.id),
type: "separator", })),
label: "Recent",
},
...responseHistoryItems,
]} ]}
> >
<IconButton <IconButton
@@ -1,17 +1,10 @@
import type { WebsocketConnection } from "@yaakapp-internal/models"; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { import { formatDistanceToNowStrict } from "date-fns";
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -26,63 +19,6 @@ export function RecentWebsocketConnectionsDropdown({
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -104,7 +40,16 @@ export function RecentWebsocketConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connectionHistoryItems, ...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -167,7 +167,7 @@ export function ResponseCookies({ response }: Props) {
{cookie.value} {cookie.value}
</span> </span>
{cookie.isDeleted && ( {cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm"> <span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
Deleted Deleted
</span> </span>
)} )}
@@ -1,6 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react"; import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -30,20 +29,12 @@ export function ResponseHeaders({ response }: Props) {
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5"> <div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}> <DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows> <KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL"> <KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span> <span className="select-text cursor-text">{response.url}</span>
<IconButton <IconButton
iconSize="sm" iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100" className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
icon="external_link" icon="external_link"
onClick={() => openUrl(response.url)} onClick={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -24,7 +24,7 @@ export function ResponseInfo({ response }: Props) {
URL URL
<IconButton <IconButton
iconSize="sm" iconSize="sm"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100" className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100"
icon="external_link" icon="external_link"
onClick={() => openUrl(response.url)} onClick={() => openUrl(response.url)}
title="Open in browser" title="Open in browser"
+1 -1
View File
@@ -10,7 +10,7 @@ export default function RouteError({ error }: { error: unknown }) {
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null; typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<VStack space={5} className="w-200 h-auto!"> <VStack space={5} className="w-[50rem] !h-auto">
<Heading>Route Error 🔥</Heading> <Heading>Route Error 🔥</Heading>
<FormattedError> <FormattedError>
{message} {message}
+2 -4
View File
@@ -19,7 +19,6 @@ type Props = Omit<ButtonProps, "type"> & {
inline?: boolean; inline?: boolean;
noun?: string; noun?: string;
help?: ReactNode; help?: ReactNode;
hideLabel?: boolean;
label?: ReactNode; label?: ReactNode;
}; };
@@ -37,7 +36,6 @@ export function SelectFile({
size = "sm", size = "sm",
label, label,
help, help,
hideLabel,
...props ...props
}: Props) { }: Props) {
const handleClick = async () => { const handleClick = async () => {
@@ -97,7 +95,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} visuallyHidden={hideLabel}> <Label htmlFor={null} help={help}>
{label} {label}
</Label> </Label>
)} )}
@@ -108,7 +106,7 @@ export function SelectFile({
"rtl mr-1.5", "rtl mr-1.5",
inline && "w-full", inline && "w-full",
filePath && inline && "font-mono text-xs", filePath && inline && "font-mono text-xs",
isHovering && "border-notice!", isHovering && "!border-notice",
)} )}
color={isHovering ? "primary" : "secondary"} color={isHovering ? "primary" : "secondary"}
onClick={handleClick} onClick={handleClick}
@@ -93,7 +93,7 @@ export default function Settings({ hide }: Props) {
layout="horizontal" layout="horizontal"
defaultValue={mainTab || tabFromQuery} defaultValue={mainTab || tabFromQuery}
addBorders addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3" tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings" label="Settings"
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
@@ -131,28 +131,28 @@ export default function Settings({ hide }: Props) {
}), }),
)} )}
> >
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
<SettingsGeneral /> <SettingsGeneral />
</TabContent> </TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsInterface /> <SettingsInterface />
</TabContent> </TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
<SettingsTheme /> <SettingsTheme />
</TabContent> </TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy /> <SettingsProxy />
</TabContent> </TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
<SettingsCertificates /> <SettingsCertificates />
</TabContent> </TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!"> <TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsLicense /> <SettingsLicense />
</TabContent> </TabContent>
</Tabs> </Tabs>
@@ -4,7 +4,6 @@ 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";
@@ -233,8 +232,6 @@ 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,64 +2,46 @@ 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 { CommercialUseBanner } from "../CommercialUseBanner"; import { Checkbox } from "../core/Checkbox";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
ModelSettingRowBoolean, import { PlainInput } from "../core/PlainInput";
ModelSettingSelectControl, import { Select } from "../core/Select";
SettingValue, import { Separator } from "../core/Separator";
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) { if (settings == null || workspace == 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> <div className="mb-4">
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">Configure general settings for update behavior and more.</p>
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">
<SettingsSection title="Updates"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<SettingRow <Select
title="Update Channel" name="updateChannel"
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"
selectClassName="w-full!" labelPosition="left"
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", value: "beta" }, { label: "Beta (more frequent)", value: "beta" },
]} ]}
/> />
<IconButton <IconButton
@@ -71,99 +53,123 @@ export function SettingsGeneral() {
onClick={() => checkForUpdates.mutateAsync()} onClick={() => checkForUpdates.mutateAsync()}
/> />
</div> </div>
</SettingRow>
<SettingRowSelect <Select
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"}
onChange={(v) => label="Update Behavior"
patchModel(settings, { autoupdate: v === "auto" }) labelPosition="left"
} 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
<ModelSettingRowBoolean className="pl-2 mt-1 ml-[14rem]"
model={settings} checked={settings.autoDownloadUpdates}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate} disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
/> />
<ModelSettingRowBoolean <Checkbox
model={settings} className="pl-2 mt-1 ml-[14rem]"
modelKey="checkNotifications" checked={settings.checkNotifications}
title="Check for notifications" title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications." help="Periodically ping Yaak servers to check for relevant notifications."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/> />
<Checkbox
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled disabled
className="pl-2 mt-1 ml-[14rem]"
checked={false} checked={false}
onChange={() => {}} title="Send anonymous usage statistics"
help="Yaak is local-first and does not collect analytics or usage data 🔐"
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/> />
</SettingsSection>
</CargoFeature> </CargoFeature>
{showWorkspaceSettingsMovedBanner && ( <Separator className="my-4" />
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="p-4 max-w-xl mx-auto"
>
<p>
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
</p>
</DismissibleBanner>
)}
<SettingsSection title="App Info"> <Heading level={2}>
<SettingRow title="Version" description="Current Yaak version."> Workspace{" "}
<SettingValue value={appInfo.version} /> <div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
</SettingRow> {workspace.name}
<SettingRow </div>
title="Data Directory" </Heading>
description="Where Yaak stores application data." <VStack className="mt-1 w-full" space={3}>
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2" <PlainInput
> required
<SettingValue size="sm"
value={appInfo.appDataDir} name="requestTimeout"
actions={[ label="Request Timeout (ms)"
{ labelClassName="w-[14rem]"
title: revealInFinderText, placeholder="0"
icon: "folder_open", labelPosition="left"
onClick: () => revealItemInDir(appInfo.appDataDir), defaultValue={`${workspace.settingRequestTimeout}`}
}, validate={(value) => Number.parseInt(value, 10) >= 0}
]} onChange={(v) =>
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
}
type="number"
/> />
</SettingRow>
<SettingRow <Checkbox
title="Logs Directory" checked={workspace.settingValidateCertificates}
description="Where Yaak writes application logs." help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2" title="Validate TLS certificates"
> onChange={(settingValidateCertificates) =>
<SettingValue patchModel(workspace, { settingValidateCertificates })
value={appInfo.appLogDir} }
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/> />
</SettingRow>
</SettingsSection> <Checkbox
</SettingsList> 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}
</KeyValueRow>
<KeyValueRow
label="Logs Directory"
rightSlot={
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appLogDir)}
/>
}
>
{appInfo.appLogDir}
</KeyValueRow>
</KeyValueRows>
</VStack> </VStack>
); );
} }
@@ -341,7 +341,7 @@ function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps
}} }}
className={classNames( className={classNames(
"flex items-center justify-center", "flex items-center justify-center",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full", "px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full",
"border-border-subtle focus:border-border-focus", "border-border-subtle focus:border-border-focus",
)} )}
> >
@@ -3,27 +3,17 @@ 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, VStack } from "@yaakapp-internal/ui"; import { clamp, Heading, HStack, Icon, 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 { import { Select } from "../core/Select";
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__"; const NULL_FONT_VALUE = "__NULL_FONT__";
@@ -48,17 +38,16 @@ export function SettingsInterface() {
} }
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={3} 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>
<SettingsList className="space-y-8"> <Select
<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"
@@ -77,25 +66,24 @@ export function SettingsInterface() {
{ label: "Open in new window", value: "new" }, { label: "Open in new window", value: "new" },
]} ]}
/> />
</SettingsSection> <HStack space={2} alignItems="end">
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && ( {fonts.data && (
<SettingSelectControl <Select
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) => ({ label: f, value: f })), ...(fonts.data.uiFonts.map((f) => ({
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })), label: 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;
@@ -103,32 +91,30 @@ export function SettingsInterface() {
}} }}
/> />
)} )}
<SettingSelectControl <Select
hideLabel
size="sm"
name="interfaceFontSize" name="interfaceFontSize"
label="Interface Font Size" label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14" defaultValue="14"
value={`${settings.interfaceFontSize}`}
options={fontSizeOptions} options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })} onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/> />
</SettingRow> </HStack>
<HStack space={2} alignItems="end">
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && ( {fonts.data && (
<SettingSelectControl <Select
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) => ({ label: f, value: f })), ...(fonts.data.editorFonts.map((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;
@@ -136,84 +122,70 @@ export function SettingsInterface() {
}} }}
/> />
)} )}
<SettingSelectControl <Select
hideLabel
size="sm"
name="editorFontSize" name="editorFontSize"
label="Editor Font Size" label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12" defaultValue="12"
value={`${settings.editorFontSize}`}
options={fontSizeOptions} options={fontSizeOptions}
onChange={(v) => onChange={(v) =>
patchModel(settings, { patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
} }
/> />
</SettingRow> </HStack>
</SettingsSection> <Select
leftSlot={<Icon icon="keyboard" color="secondary" />}
<SettingsSection title="Editor"> size="sm"
<ModelSettingRowSelect name="editorKeymap"
model={settings} label="Editor keymap"
modelKey="editorKeymap" value={`${settings.editorKeymap}`}
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps} options={keymaps}
onChange={(v) => patchModel(settings, { editorKeymap: v })}
/> />
<ModelSettingRowBoolean <Checkbox
model={settings} checked={settings.editorSoftWrap}
modelKey="editorSoftWrap"
title="Wrap editor lines" title="Wrap editor lines"
description="Wrap long lines in request and response editors." onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/> />
<ModelSettingRowBoolean <Checkbox
model={settings} checked={settings.coloredMethods}
modelKey="coloredMethods"
title="Colorize request methods" title="Colorize request methods"
description="Use method-specific colors for HTTP request methods." onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/> />
</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 (
<SettingRow <div className="flex gap-1 overflow-hidden h-2xs">
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="xs" size="2xs"
onClick={async () => { onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar }); await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart"); await invokeCmd("cmd_restart");
@@ -222,7 +194,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
Apply and Restart Apply and Restart
</Button> </Button>
)} )}
</SettingRow> </div>
); );
} }
@@ -233,11 +205,9 @@ function LicenseSettings({ settings }: { settings: Settings }) {
} }
return ( return (
<SettingsSection title="License"> <Checkbox
<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({
@@ -253,9 +223,7 @@ 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={pricingUrl("app.license.badge-hide-confirm")}> <Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
Purchase a License
</Link>
</p> </p>
</VStack> </VStack>
), ),
@@ -263,12 +231,11 @@ function LicenseSettings({ settings }: { settings: Settings }) {
color: "info", color: "info",
}); });
if (!confirmed) { if (!confirmed) {
return; return; // Cancel
} }
} }
await patchModel(settings, { hideLicenseBadge }); await patchModel(settings, { hideLicenseBadge });
}} }}
/> />
</SettingsSection>
); );
} }
@@ -6,7 +6,6 @@ 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";
@@ -49,7 +48,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={pricingUrl(`app.license.learn.${check.data.status}`)}> <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -69,7 +68,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={pricingUrl(`app.license.learn.${check.data.status}`)}> <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -135,7 +134,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")} onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -151,7 +150,9 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`)) openUrl(
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -211,7 +211,7 @@ function PluginTableRow({
return ( return (
<TableRow> <TableRow>
{showCheckbox && ( {showCheckbox && (
<TableCell className="py-0!"> <TableCell className="!py-0">
<Checkbox <Checkbox
hideLabel hideLabel
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"} title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
@@ -249,7 +249,7 @@ function PluginTableRow({
)} )}
</HStack> </HStack>
</TableCell> </TableCell>
<TableCell className="py-0!"> <TableCell className="!py-0">
<HStack justifyContent="end" space={1.5}> <HStack justifyContent="end" space={1.5}>
{plugin != null && latestVersion != null ? ( {plugin != null && latestVersion != null ? (
<Button <Button
@@ -1,29 +1,13 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models"; import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner"; import { Checkbox } from "../core/Checkbox";
import { import { PlainInput } from "../core/PlainInput";
SettingRowBoolean, import { Select } from "../core/Select";
SettingRowSelect, import { Separator } from "../core/Separator";
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">
@@ -34,19 +18,26 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" /> <Select
<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, { proxy }); await patchModel(settings, {
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" } });
} }
@@ -56,125 +47,159 @@ 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}>
<SettingsSection title="Custom Proxy"> <Checkbox
<SettingRowBoolean className="my-3"
checked={!settings.proxy.disabled} checked={!settings.proxy.disabled}
title="Enable proxy" title="Enable proxy"
description="Temporarily disable the proxy without losing the configuration." help="Use this to temporarily disable the proxy without losing the configuration"
onChange={(enabled) => patchProxy({ disabled: !enabled })} onChange={async (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 },
});
}}
/> />
<SettingRowText <HStack space={1.5}>
name="proxyHttp" <PlainInput
title={ size="sm"
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"
onChange={(http) => patchProxy({ http })} defaultValue={settings.proxy?.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,
},
});
}}
/> />
<SettingRowText <PlainInput
name="proxyHttps" size="sm"
title={ label={
<> <>
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"
onChange={(https) => patchProxy({ https })} defaultValue={settings.proxy?.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 },
});
}}
/> />
<SettingRowText </HStack>
name="proxyBypass" <Separator className="my-6" />
title="Proxy Bypass" <Checkbox
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"
description="Send proxy credentials with proxied requests." onChange={async (enabled) => {
onChange={(enabled) => const { proxy } = settings;
patchProxy({ auth: enabled ? { user: "", password: "" } : null }) 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 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}>
<SettingRowText <PlainInput
required required
name="proxyUser" size="sm"
title="User" label="User"
description="Username for proxy authentication."
value={settings.proxy.auth.user}
placeholder="myUser" placeholder="myUser"
onChange={(user) => defaultValue={settings.proxy.auth.user}
patchProxy({ onChange={async (user) => {
auth: { const { proxy } = settings;
user, const http = proxy?.type === "enabled" ? proxy.http : "";
password: const https = proxy?.type === "enabled" ? proxy.https : "";
settings.proxy?.type === "enabled" const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
? (settings.proxy.auth?.password ?? "") 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 },
});
}}
/> />
<SettingRowText <PlainInput
name="proxyPassword" size="sm"
title="Password" label="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password" type="password"
onChange={(password) => placeholder="s3cretPassw0rd"
patchProxy({ defaultValue={settings.proxy.auth.password}
auth: { onChange={async (password) => {
user: const { proxy } = settings;
settings.proxy?.type === "enabled" const http = proxy?.type === "enabled" ? proxy.http : "";
? (settings.proxy.auth?.user ?? "") const https = proxy?.type === "enabled" ? proxy.https : "";
: "", const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
password, 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" />
<PlainInput
label="Proxy Bypass"
help="Comma-separated list to bypass the proxy."
defaultValue={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
onChange={async (bypass) => {
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 user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/> />
</> </>
)} )}
</SettingsSection> </VStack>
</>
)} )}
</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,12 +9,7 @@ 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 { import { Select } from "../core/Select";
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 })));
@@ -72,7 +67,7 @@ export function SettingsTheme() {
})); }));
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={3} 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">
@@ -82,45 +77,51 @@ export function SettingsTheme() {
</Link> </Link>
</p> </p>
</div> </div>
<SettingsList className="space-y-8"> <Select
<SettingsSection title="Theme"> name="appearance"
<ModelSettingRowSelect label="Appearance"
model={settings} labelPosition="top"
modelKey="appearance" size="sm"
title="Appearance" value={settings.appearance}
description="Choose whether Yaak follows your system appearance or uses a fixed mode." onChange={(appearance) => patchModel(settings, { appearance })}
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") && (
<SettingRowSelect <Select
hideLabel
leftSlot={<Icon icon="sun" color="secondary" />}
name="lightTheme" name="lightTheme"
title="Light theme" label="Light Theme"
description="Theme used when Yaak is in light mode." size="sm"
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") && (
<SettingRowSelect <Select
hideLabel
name="darkTheme" name="darkTheme"
title="Dark theme" className="flex-1"
description="Theme used when Yaak is in dark mode." label="Dark Theme"
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 })}
/> />
)} )}
</SettingsSection> </HStack>
<SettingsSection title="Preview">
<VStack <VStack
space={3} space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto" className="mt-3 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"} />
@@ -166,8 +167,6 @@ export function SettingsTheme() {
/> />
</Suspense> </Suspense>
</VStack> </VStack>
</SettingsSection>
</SettingsList>
</VStack> </VStack>
); );
} }
@@ -7,7 +7,6 @@ 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";
@@ -77,8 +76,7 @@ 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: () => onSelect: () => openUrl("https://yaak.app/pricing"),
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
+7 -113
View File
@@ -64,9 +64,7 @@ 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 {
@@ -81,7 +79,6 @@ 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";
@@ -111,7 +108,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, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; const [tree, allFields] = 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);
@@ -230,7 +227,7 @@ function Sidebar({ className }: { className?: string }) {
); );
const clearFilterText = useCallback(() => { const clearFilterText = useCallback(() => {
setSidebarFilterText(""); jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
requestAnimationFrame(() => { requestAnimationFrame(() => {
filterRef.current?.focus(); filterRef.current?.focus();
}); });
@@ -255,13 +252,6 @@ 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(
@@ -588,7 +578,7 @@ function Sidebar({ className }: { className?: string }) {
rightSlot={ rightSlot={
filterText.text && ( filterText.text && (
<IconButton <IconButton
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1" className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x" icon="x"
title="Clear filter" title="Clear filter"
onClick={clearFilterText} onClick={clearFilterText}
@@ -664,43 +654,8 @@ function Sidebar({ className }: { className?: string }) {
)} )}
</div> </div>
{allHidden ? ( {allHidden ? (
<div className="p-3 text-sm text-center"> <div className="italic text-text-subtle p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? ( No results for <InlineCode>{filterText.text}</InlineCode>
<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-sm align-middle focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{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
@@ -831,48 +786,7 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "", key: "",
}); });
type SidebarFilterSuggestion = { const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
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);
@@ -893,11 +807,9 @@ const sidebarTreeAtom = atom<
} }
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;
@@ -909,13 +821,6 @@ const sidebarTreeAtom = atom<
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) {
@@ -969,18 +874,7 @@ const sidebarTreeAtom = atom<
values: Array.from(values).filter((v) => v.length < 20), values: Array.from(values).filter((v) => v.length < 20),
}); });
} }
const suggestions = Array.from(suggestionFields) return [root, fields] as const;
.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,79 +4,20 @@ 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 && (
@@ -106,7 +47,18 @@ 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 }) => handleFilePathChange(filePath)} onChange={async ({ 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" && (
@@ -208,10 +208,10 @@ function InitializedTemplateFunctionDialog({
)} )}
/> />
</HStack> </HStack>
<div className="relative w-full max-h-40"> <div className="relative w-full max-h-[10rem]">
<InlineCode <InlineCode
className={classNames( className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!", "block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest",
tooLarge && "italic text-danger", tooLarge && "italic text-danger",
)} )}
> >
@@ -246,7 +246,7 @@ function InitializedTemplateFunctionDialog({
) : ( ) : (
<span /> <span />
)} )}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1"> <div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.data.name === "secure" && ( {templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}> <Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key Reveal Encryption Key
@@ -271,7 +271,7 @@ TemplateFunctionDialog.show = (
showDialog({ showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md", size: "md",
className: "h-240", className: "h-[60rem]",
noPadding: true, noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>, title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description, description: fn.description,
+1 -1
View File
@@ -94,7 +94,7 @@ export const UrlBar = memo(function UrlBar({
iconSize="md" iconSize="md"
title="Send Request" title="Send Request"
type="submit" type="submit"
className="w-8 mr-0.5 h-full!" className="w-8 mr-0.5 !h-full"
iconColor="secondary" iconColor="secondary"
icon={isLoading ? "x" : submitIcon} icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send" hotkeyAction="request.send"
@@ -21,7 +21,6 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms"; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
@@ -35,7 +34,6 @@ 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";
@@ -50,7 +48,6 @@ 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";
@@ -72,7 +69,6 @@ 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(
@@ -84,7 +80,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
); );
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url); const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? "",
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) { for (const name of placeholderNames) {
@@ -111,17 +109,12 @@ 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, numSettingsOverrides, urlParameterPairs.length]); }, [authTab, headersTab, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -217,7 +210,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
title="Close connection" title="Close connection"
icon="x" icon="x"
iconColor="secondary" iconColor="secondary"
className="w-8 mr-0.5 h-full!" className="w-8 mr-0.5 !h-full"
onClick={handleCancel} onClick={handleCancel}
/> />
) )
@@ -236,7 +229,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
ref={tabsRef} ref={tabsRef}
label="Request" label="Request"
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 mb-1.5!" tabListClassName="mt-1 !mb-1.5"
storageKey={TABS_STORAGE_KEY} storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId} activeTabKey={activeRequestId}
> >
@@ -273,9 +266,6 @@ 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
@@ -283,7 +273,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
hideLabel hideLabel
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name} defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!" className="font-sans !text-xl !px-0"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModel(activeRequest, { name })}
@@ -105,18 +105,10 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "error" messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "error" messageType === "close" || messageType === "open"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -127,8 +119,6 @@ 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>
) : ( ) : (
@@ -180,8 +170,6 @@ 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[] =
+2 -8
View File
@@ -6,7 +6,6 @@ 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";
@@ -34,7 +33,6 @@ 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";
@@ -85,7 +83,7 @@ export function Workspace() {
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" /> <div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div <div
style={environmentBgStyle} style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-px h-px opacity-20" className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
/> />
</div> </div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} /> <WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
@@ -162,7 +160,7 @@ function WorkspaceBody() {
// Delay the entering because the workspaces might load after a slight delay // Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
<Banner color="warning" className="max-w-120"> <Banner color="warning" className="max-w-[30rem]">
The active workspace was not found. Select a workspace from the header menu or report this The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink /> bug to <FeedbackLink />
</Banner> </Banner>
@@ -220,8 +218,4 @@ 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,
});
} }
@@ -176,7 +176,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
"text px-2! truncate", "text !px-2 truncate",
workspace === null && "italic opacity-disabled", workspace === null && "italic opacity-disabled",
)} )}
{...buttonProps} {...buttonProps}
@@ -20,24 +20,16 @@ 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({ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
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);
@@ -74,7 +66,7 @@ export function WorkspaceEncryptionSetting({
key.error != null || key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) { ) {
const enterKey = ( return (
<EnterWorkspaceKey <EnterWorkspaceKey
workspaceMeta={workspaceMeta} workspaceMeta={workspaceMeta}
error={key.error} error={key.error}
@@ -87,8 +79,6 @@ export function WorkspaceEncryptionSetting({
}} }}
/> />
); );
return enterKey;
} }
// Show the key if it exists // Show the key if it exists
@@ -100,8 +90,7 @@ export function WorkspaceEncryptionSetting({
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">
@@ -122,43 +111,9 @@ export function WorkspaceEncryptionSetting({
)} )}
</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
@@ -324,7 +279,7 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) { function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return ( return (
<span className="text-xs font-mono **:cursor-auto **:select-text"> <span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
{show ? ( {show ? (
keyText.split("").map((c, i) => { keyText.split("").map((c, i) => {
return ( return (
@@ -1,23 +1,20 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; import { Banner, HStack, InlineCode, VStack } 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";
@@ -28,17 +25,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_SETTINGS; | typeof TAB_DATA;
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL; const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
@@ -74,8 +71,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
tabs={[ tabs={[
{ value: TAB_GENERAL, label: "Workspace" }, { value: TAB_GENERAL, label: "Workspace" },
{ {
value: TAB_SETTINGS, value: TAB_DATA,
label: "Settings", label: "Storage",
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
@@ -103,22 +100,6 @@ 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
@@ -127,7 +108,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
placeholder="Workspace Name" placeholder="Workspace Name"
label="Name" label="Name"
defaultValue={workspace.name} defaultValue={workspace.name}
className="text-base! font-sans" className="!text-base font-sans"
onChange={(name) => patchModel(workspace, { name })} onChange={(name) => patchModel(workspace, { name })}
/> />
@@ -161,7 +142,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5"> <InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{workspaceId} {workspaceId}
<CopyIconButton <CopyIconButton
className="opacity-70 text-primary!" className="opacity-70 !text-primary"
size="2xs" size="2xs"
iconSize="sm" iconSize="sm"
title="Copy workspace ID" title="Copy workspace ID"
@@ -171,21 +152,19 @@ 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-200!",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
};
@@ -72,7 +72,7 @@ export function AutoScroller<T>({
size="sm" size="sm"
iconSize="md" iconSize="md"
variant="border" variant="border"
className="bg-surface! z-10" className="!bg-surface z-10"
onClick={() => setAutoScroll((v) => !v)} onClick={() => setAutoScroll((v) => !v)}
/> />
</div> </div>
@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full overflow-y-auto focus:outline-hidden" className="h-full w-full overflow-y-auto focus:outline-none"
onScroll={handleScroll} onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined} tabIndex={focusable ? 0 : undefined}
> >
@@ -1,36 +0,0 @@
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(formatBulkPairLine) .map(pairToLine)
.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(parseBulkPairLine); .map(lineToPair);
onChange(pairs); onChange(pairs);
}, },
[onChange], [onChange],
@@ -47,16 +47,16 @@ export function BulkPairEditor({
); );
} }
export function formatBulkPairLine(pair: Pair) { function pairToLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n"); const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`; return `${pair.name}: ${value}`;
} }
export function parseBulkPairLine(line: string): PairWithId { function lineToPair(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? []; const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
return { return {
enabled: true, enabled: true,
name: (name ?? line).trim(), name: (name ?? "").trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(), value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(), id: generateId(),
}; };
@@ -13,7 +13,6 @@ export interface CheckboxProps {
hideLabel?: boolean; hideLabel?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
help?: ReactNode; help?: ReactNode;
size?: "sm" | "md";
} }
export function Checkbox({ export function Checkbox({
@@ -26,7 +25,6 @@ export function Checkbox({
hideLabel, hideLabel,
fullWidth, fullWidth,
help, help,
size = "sm",
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
<HStack <HStack
@@ -39,10 +37,8 @@ export function Checkbox({
<input <input
aria-hidden aria-hidden
className={classNames( className={classNames(
"appearance-none shrink-0 border border-border", "appearance-none w-4 h-4 flex-shrink-0 border border-border",
size === "sm" && "w-4 h-4", "rounded outline-none ring-0",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]", !disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted", disabled && "border-dotted",
)} )}
@@ -54,7 +50,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={size} size="sm"
className={classNames(disabled && "opacity-disabled")} className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"} icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/> />
@@ -17,7 +17,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
<div className={className}> <div className={className}>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="w-full!" className="!w-full"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -96,7 +96,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
<> <>
<HexColorPicker <HexColorPicker
color={color ?? undefined} color={color ?? undefined}
className="w-full!" className="!w-full"
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
regenerateKey(); // To force input to change regenerateKey(); // To force input to change
@@ -18,7 +18,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
className={classNames( className={classNames(
className, className,
"flex items-center", "flex items-center",
"opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono", "opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono",
color == null && "border-border-subtle", color == null && "border-border-subtle",
color === "primary" && "text-primary", color === "primary" && "text-primary",
color === "secondary" && "text-secondary", color === "secondary" && "text-secondary",
@@ -42,7 +42,7 @@ export function DetailsBanner({
return ( return (
<Banner color={color} className={className}> <Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}> <details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70"> <summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
<div <div
className={classNames( className={classNames(
"transition-transform", "transition-transform",
+6 -6
View File
@@ -74,13 +74,13 @@ export function Dialog({
"relative bg-surface pointer-events-auto", "relative bg-surface pointer-events-auto",
"rounded-lg", "rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]", "border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40", "min-h-[10rem]",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]", "max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120", size === "sm" && "w-[30rem]",
size === "md" && "w-200", size === "md" && "w-[50rem]",
size === "lg" && "w-280", size === "lg" && "w-[70rem]",
size === "full" && "w-screen h-screen", size === "full" && "w-[100vw] h-[100vh]",
size === "dynamic" && "min-w-80 max-w-[100vw]", size === "dynamic" && "min-w-[20rem] max-w-[100vw]",
)} )}
> >
{title ? ( {title ? (
@@ -1,111 +1,57 @@
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 } from "@yaakapp-internal/ui"; import { Banner, HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { MouseEvent } from "react";
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";
type DismissibleBannerSize = "sm" | "xs";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
size = "sm",
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
size?: DismissibleBannerSize; 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 { const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
const shouldShow = !isLoading && !dismissed; if (dismissed) return null;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
const stopParentClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<Banner <Banner
className={classNames( className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
{...props} {...props}
>
<div className="@container">
<div
className={classNames(
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
)}
> >
{children} {children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end"> <HStack space={1.5}>
<Button
variant="border"
color={props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
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={a.variant ?? "border"} variant="border"
color={a.color ?? props.color} color={a.color ?? props.color}
size={actionSize} size="xs"
onClick={(event) => { onClick={a.onClick}
stopParentClick(event);
a.onClick();
}}
title={a.label} title={a.label}
> >
{a.label} {a.label}
</Button> </Button>
))} ))}
</div> <Button
</div> variant="border"
</div> color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
>
Dismiss
</Button>
</HStack>
</Banner> </Banner>
); );
} }
+13 -13
View File
@@ -712,7 +712,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
className={classNames( className={classNames(
className, className,
"x-theme-menu", "x-theme-menu",
"outline-hidden my-1 pointer-events-auto z-40", "outline-none my-1 pointer-events-auto z-40",
"fixed", "fixed",
)} )}
> >
@@ -734,7 +734,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
{filter && ( {filter && (
<HStack <HStack
space={2} space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded-sm font-mono h-xs" className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
> >
<Icon icon="search" size="xs" /> <Icon icon="search" size="xs" />
<div className="text">{filter}</div> <div className="text">{filter}</div>
@@ -916,24 +916,24 @@ function MenuItem({
) )
} }
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="text-left!" innerClassName="!text-left"
color="custom" color="custom"
className={classNames( className={classNames(
className, className,
"h-xs", // More compact "h-xs", // More compact
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap", "min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1", "focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm", isParentOfActiveSubmenu && "bg-surface-highlight text rounded",
item.color === "danger" && "text-danger!", item.color === "danger" && "!text-danger",
item.color === "primary" && "text-primary!", item.color === "primary" && "!text-primary",
item.color === "success" && "text-success!", item.color === "success" && "!text-success",
item.color === "warning" && "text-warning!", item.color === "warning" && "!text-warning",
item.color === "notice" && "text-notice!", item.color === "notice" && "!text-notice",
item.color === "info" && "text-info!", item.color === "info" && "!text-info",
)} )}
{...props} {...props}
> >
<div className={classNames("truncate min-w-20")}>{item.label}</div> <div className={classNames("truncate min-w-[5rem]")}>{item.label}</div>
</Button> </Button>
); );
} }
@@ -1,5 +1,3 @@
@reference "../../../main.css";
.cm-wrapper.cm-multiline .cm-mergeView { .cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5; @apply h-full w-full overflow-auto pr-0.5;
@@ -11,7 +9,7 @@
@apply w-full min-h-full relative; @apply w-full min-h-full relative;
.cm-collapsedLines { .cm-collapsedLines {
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default; @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
} }
} }
@@ -21,21 +19,21 @@
.cm-changedLine { .cm-changedLine {
/* Round top corners only if previous line is not a changed line */ /* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) { &:not(.cm-changedLine + &) {
@apply rounded-t-sm; @apply rounded-t;
} }
/* Round bottom corners only if next line is not a changed line */ /* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) { &:not(:has(+ .cm-changedLine)) {
@apply rounded-b-sm; @apply rounded-b;
} }
} }
/* Let content grow and disable individual scrolling for sync */ /* Let content grow and disable individual scrolling for sync */
.cm-editor { .cm-editor {
@apply h-auto! relative!; @apply h-auto relative !important;
position: relative !important; position: relative !important;
} }
.cm-scroller { .cm-scroller {
@apply overflow-visible!; @apply overflow-visible !important;
} }
} }
@@ -1,5 +1,3 @@
@reference "../../../main.css";
.cm-wrapper { .cm-wrapper {
@apply h-full overflow-hidden; @apply h-full overflow-hidden;
@@ -9,7 +7,7 @@
/* Regular cursor */ /* Regular cursor */
.cm-cursor { .cm-cursor {
@apply border-text!; @apply border-text !important;
/* Widen the cursor a bit */ /* Widen the cursor a bit */
@apply border-l-[2px]; @apply border-l-[2px];
} }
@@ -17,8 +15,8 @@
/* Vim-mode cursor */ /* Vim-mode cursor */
.cm-fat-cursor { .cm-fat-cursor {
@apply outline-0! bg-text!; @apply outline-0 bg-text !important;
@apply text-surface!; @apply text-surface !important;
} }
/* Matching bracket */ /* Matching bracket */
@@ -61,12 +59,12 @@
* { * {
@apply cursor-text; @apply cursor-text;
@apply caret-transparent!; @apply caret-transparent !important;
} }
} }
.cm-selectionBackground { .cm-selectionBackground {
@apply bg-selection!; @apply bg-selection !important;
} }
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost /* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
@@ -90,7 +88,7 @@
} }
.cm-gutter-lint { .cm-gutter-lint {
@apply w-auto!; @apply w-auto !important;
.cm-gutterElement { .cm-gutterElement {
@apply px-0; @apply px-0;
@@ -113,7 +111,7 @@
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default; @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border hover:text-text hover:bg-surface-highlight; @apply hover:border-border hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow; @apply inline border px-1 mx-[0.5px] rounded dark:shadow;
-webkit-text-security: none; -webkit-text-security: none;
@@ -164,7 +162,7 @@
&::-webkit-scrollbar-corner, &::-webkit-scrollbar-corner,
&::-webkit-scrollbar { &::-webkit-scrollbar {
@apply hidden!; @apply hidden !important;
} }
} }
} }
@@ -191,16 +189,16 @@
/* Style search matches */ /* Style search matches */
.cm-searchMatch { .cm-searchMatch {
@apply bg-transparent!; @apply bg-transparent !important;
@apply rounded-[2px] outline outline-1; @apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected { &.cm-searchMatch-selected {
@apply outline-text; @apply outline-text;
@apply bg-text!; @apply bg-text !important;
&, &,
* { * {
@apply text-surface! font-semibold!; @apply text-surface font-semibold !important;
} }
} }
} }
@@ -225,8 +223,8 @@
} }
.cm-editor .fold-gutter-icon { .cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm; @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;
@apply cursor-default!; @apply cursor-default !important;
} }
.cm-editor .fold-gutter-icon::after { .cm-editor .fold-gutter-icon::after {
@@ -250,7 +248,7 @@
.cm-editor .cm-foldPlaceholder { .cm-editor .cm-foldPlaceholder {
@apply px-2 border border-border-subtle bg-surface-highlight; @apply px-2 border border-border-subtle bg-surface-highlight;
@apply hover:text-text hover:border-border-subtle text-text; @apply hover:text-text hover:border-border-subtle text-text;
@apply cursor-default!; @apply cursor-default !important;
} }
.cm-editor .cm-activeLineGutter { .cm-editor .cm-activeLineGutter {
@@ -279,7 +277,7 @@
} }
.cm-tooltip-lint { .cm-tooltip-lint {
@apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!; @apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important;
.cm-diagnostic-error { .cm-diagnostic-error {
@apply border-l-danger px-4 py-2; @apply border-l-danger px-4 py-2;
@@ -295,18 +293,18 @@
} }
.cm-tooltip.cm-tooltip-hover { .cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm; @apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
@apply p-1.5; @apply p-1.5;
/* Style the tooltip for popping up "open in browser" and other stuff */ /* Style the tooltip for popping up "open in browser" and other stuff */
a, a,
button { button {
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm; @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded;
} }
a { a {
@apply cursor-default!; @apply cursor-default !important;
&::after { &::after {
@apply text-text bg-text h-3 w-3 ml-1; @apply text-text bg-text h-3 w-3 ml-1;
@@ -321,10 +319,10 @@
/* NOTE: Extra selector required to override default styles */ /* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete, .cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo { .cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto; @apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto;
& * { & * {
@apply font-mono! text-editor!; @apply font-mono text-editor !important;
} }
.cm-completionIcon { .cm-completionIcon {
@@ -411,7 +409,7 @@
} }
.cm-completionIcon { .cm-completionIcon {
@apply text-sm flex items-center pb-0.5 shrink-0; @apply text-sm flex items-center pb-0.5 flex-shrink-0;
} }
.cm-completionLabel { .cm-completionLabel {
@@ -429,7 +427,7 @@
input, input,
button { button {
@apply rounded-sm outline-hidden; @apply rounded-sm outline-none;
} }
button { button {
@@ -438,12 +436,12 @@
} }
button[name="close"] { button[name="close"] {
@apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!; @apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important;
} }
input { input {
@apply bg-surface border-border-subtle focus:border-border-focus; @apply bg-surface border-border-subtle focus:border-border-focus;
@apply border outline-hidden; @apply border outline-none;
} }
input.cm-textfield { input.cm-textfield {
@@ -282,22 +282,6 @@ function EditorInner({
[disableTabIndent], [disableTabIndent],
); );
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
@@ -410,9 +394,9 @@ function EditorInner({
keymapCompartment.current.of( keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
), ),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
...getExtensions({ ...getExtensions({
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
stateKey, stateKey,
@@ -486,7 +470,7 @@ function EditorInner({
const decoratedActions = useMemo(() => { const decoratedActions = useMemo(() => {
const results = []; const results = [];
const actionClassName = classNames( const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow", "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow",
); );
if (format) { if (format) {
@@ -569,6 +553,7 @@ function EditorInner({
function getExtensions({ function getExtensions({
stateKey, stateKey,
container, container,
readOnly,
singleLine, singleLine,
hideGutter, hideGutter,
onChange, onChange,
@@ -577,7 +562,7 @@ function getExtensions({
onFocus, onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
}: Pick<EditorProps, "singleLine" | "hideGutter"> & { }: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & {
stateKey: EditorProps["stateKey"]; stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>; onChange: RefObject<EditorProps["onChange"]>;
@@ -595,10 +580,6 @@ 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?.();
@@ -627,6 +608,7 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []), ...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ // // ------------------------ //
// Things that must be last // // Things that must be last //
@@ -15,9 +15,8 @@ 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 FIELD_IDENT = /[A-Za-z0-9_/]+$/; const IDENT = /[A-Za-z0-9_/]+$/;
const VALUE_IDENT = /\S+$/; const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
const VALUE_IDENT_ONLY = /^\S+$/;
function normalizeFields(fields: FieldDef[]): { function normalizeFields(fields: FieldDef[]): {
fieldNames: string[]; fieldNames: string[];
@@ -32,37 +31,14 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap }; return { fieldNames, fieldMap };
} }
function wordBefore( function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
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(pattern); const m = upto.match(IDENT);
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);
@@ -105,7 +81,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(FIELD_IDENT); const m = beforeColon.match(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?
@@ -117,16 +93,15 @@ function contextInfo(stateDoc: string, pos: number) {
} }
/** Build a completion list for field names */ /** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] { function fieldNameCompletions(fieldNames: string[]): 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) => {
// Leave cursor right after the field filter colon. // Insert "name:" (leave cursor right after colon)
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({ view.dispatch({
changes: { from, to, insert }, changes: { from, to, insert: `${name}:` },
selection: { anchor: from + insert.length }, selection: { anchor: from + name.length + 1 },
}); });
startCompletion(view); startCompletion(view);
}, },
@@ -140,7 +115,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(VALUE_IDENT_ONLY) ? v : `"${v}"`, label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v, displayLabel: v,
type: "constant", type: "constant",
})); }));
@@ -157,13 +132,14 @@ 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);
@@ -186,11 +162,7 @@ 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 completion = fieldCompletionFrom(doc, pos); const options: Completion[] = fieldNameCompletions(fieldNames);
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,11 +2,10 @@
@skip { space+ } @skip { space+ }
@tokens { @tokens {
space { $[ \t\r\n]+ } space { std.whitespace+ }
LParen { "(" } LParen { "(" }
RParen { ")" } RParen { ")" }
At { "@" }
Colon { ":" } Colon { ":" }
Not { "-" | "NOT" } Not { "-" | "NOT" }
@@ -17,10 +16,8 @@
// "quoted phrase" with simple escapes: \" and \\ // "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' } Phrase { '"' (!["\\] | "\\" _)* '"' }
// Bare words run until filter syntax or whitespace. Leading '-' remains unary // field/word characters (keep generous for URLs/paths)
// negation, but '-' may appear after the first character. Word { $[A-Za-z0-9_]+ }
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word } @precedence { Not, And, Or, Word }
} }
@@ -63,12 +60,12 @@ Field {
} }
FieldName { FieldName {
At? Word Word
} }
FieldValue { FieldValue {
Phrase Phrase
| FieldValueWord | Term
} }
Term { Term {
@@ -1,42 +0,0 @@
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,22 +1,27 @@
/* 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: "%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", states:
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~", "%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",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog", stateData:
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or", "$]~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~",
maxTerm: 27, goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
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,22], skippedNodes: [0, 20],
repeatNodeCount: 3, repeatNodeCount: 3,
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", tokenData:
tokenizers: [0, 1], ")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%[",
topRules: {"Query":[0,1]}, tokenizers: [0],
tokenPrec: 145 topRules: { Query: [0, 1] },
}) tokenPrec: 145,
});
@@ -1,43 +0,0 @@
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);
});
});
@@ -1,7 +0,0 @@
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,7 +16,6 @@ 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/FieldValueWord": t.attributeValue, "FieldValue/Term/Word": t.attributeValue,
}); });
@@ -30,8 +30,7 @@ type Tok =
| { kind: "EOF" }; | { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c); const isSpace = (c: string) => /\s/.test(c);
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c); const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.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[] = [];
@@ -43,13 +42,7 @@ export function tokenize(input: string): Tok[] {
const readWord = () => { const readWord = () => {
let s = ""; let s = "";
while (i < n && isWordChar(peek())) s += advance(); while (i < n && isIdent(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s; return s;
}; };
@@ -92,9 +85,6 @@ 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 === `"`) {
@@ -109,7 +99,7 @@ export function tokenize(input: string): Tok[] {
} }
// WORD / AND / OR / NOT // WORD / AND / OR / NOT
if (isWordStart(c)) { if (isIdent(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 { ":" $[ \t]+ } Sep { ":" }
Key { ":"? ![:]+ } Key { ":"? ![:]+ }
Value { ![\n]+ } Value { ![\n]+ }
} }
@@ -1,26 +0,0 @@
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#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![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#vVRQSSOYhYZ!UZ![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,
@@ -53,17 +53,19 @@ function pathParameters(
if (node.name === "Text") { if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders // Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) { for (let i = node.from; i < node.to; i++) {
const innerTree = tree.resolveInner(i); const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === "url") { if (innerTree.node.name === "url") {
innerTree.node.cursor().iterate((node) => { innerTree.toTree().iterate({
enter(node) {
if (node.name !== "Placeholder") return; if (node.name !== "Placeholder") return;
const globalFrom = node.from; const globalFrom = innerTree.node.from + node.from;
const globalTo = node.to; const globalTo = innerTree.node.from + node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo); const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const onClick = () => onClickPathParameter(rawText); const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const deco = Decoration.replace({ widget, inclusive: false }); const deco = Decoration.replace({ widget, inclusive: false });
widgets.push(deco.range(globalFrom, globalTo)); widgets.push(deco.range(globalFrom, globalTo));
},
}); });
break; break;
} }
@@ -1,13 +1,6 @@
// Host is optional so URLs starting with `/` go straight to Path. Without this, @top url { Protocol? Host Path? Query? }
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }
Path { ("/" PathSegment)+ } Path { ("/" (Placeholder | PathSegment))+ }
Placeholder { ":" pathChars }
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
Query { "?" queryPair ("&" queryPair)* } Query { "?" queryPair ("&" queryPair)* }
@@ -16,7 +9,9 @@ Query { "?" queryPair ("&" queryPair)* }
Host { $[a-zA-Z0-9-_.:\[\]]+ } Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host } @precedence { Protocol, Host }
pathChars { ![/?#:]+ } Placeholder { ":" ![/?#]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
} }
@@ -1,9 +1,9 @@
// This file was generated by lezer-generator. You probably shouldn't edit it. // This file was generated by lezer-generator. You probably shouldn't edit it.
export const export const url = 1,
url = 1,
Protocol = 2, Protocol = 2,
Host = 3, Host = 3,
Path = 4, Port = 4,
PathSegment = 5, Path = 5,
Placeholder = 6, Placeholder = 6,
Query = 7 PathSegment = 7,
Query = 8;
@@ -1,52 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";
function expectValidParse(input: string) {
expect(parser.parse(input).toString()).not.toContain("⚠");
}
function placeholderValues(input: string): string[] {
const values: string[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
});
return values;
}
describe("URL grammar Placeholder", () => {
test("recognizes path placeholders", () => {
expectValidParse("https://x.com/users/:id");
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
});
test("treats a colon suffix as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
});
test("treats repeated colon suffixes as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
});
test("does not recognize a colon in the middle of a plain path segment", () => {
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
});
test("does not recognize query parameters as path placeholders", () => {
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
});
test("recognizes placeholders in a path fragment after a templated base URL", () => {
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
// as in `${[ URL ]}/x/:foo/:hello`.
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
":foo",
":hello",
]);
});
});

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