mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-03 11:31:47 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab785b18a4 | |||
| 947e3f2e97 | |||
| 8b1f5e807f |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateTitle(title) {
|
|
||||||
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeTableText(value) {
|
|
||||||
return escapeHtml(value).replace(/\n/g, "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
|
||||||
const comment =
|
|
||||||
analysis == null
|
|
||||||
? "None"
|
|
||||||
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
|
|
||||||
const summary = {
|
|
||||||
blocked: analysis?.blockers.length > 0,
|
|
||||||
comment,
|
|
||||||
details: "None",
|
|
||||||
labels:
|
|
||||||
analysis?.desiredLabels.length > 0
|
|
||||||
? analysis.desiredLabels.join(", ")
|
|
||||||
: "None",
|
|
||||||
number: pr.number,
|
|
||||||
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
|
|
||||||
status: "In scope",
|
|
||||||
title: escapeHtml(truncateTitle(pr.title)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (skipped) {
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
blocked: false,
|
|
||||||
comment: "None",
|
|
||||||
details: escapeHtml(skipReason),
|
|
||||||
labels: "None",
|
|
||||||
status: "Skipped",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.blocked) {
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
comment: escapeTableText(summary.comment),
|
|
||||||
details: escapeHtml(
|
|
||||||
analysis.blockers.map((blocker) => blocker.message).join("; "),
|
|
||||||
),
|
|
||||||
labels: escapeHtml(summary.labels),
|
|
||||||
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
comment: escapeTableText(summary.comment),
|
|
||||||
labels: escapeHtml(summary.labels),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wasCreatedBefore(value, cutoff) {
|
|
||||||
return Date.parse(value) < Date.parse(cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
|
||||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
username: pr.user.login,
|
|
||||||
});
|
|
||||||
|
|
||||||
return MAINTAINER_PERMISSIONS.has(response.data.permission);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureManagedLabels({ github, owner, repo }) {
|
|
||||||
for (const label of Object.values(LABELS)) {
|
|
||||||
try {
|
|
||||||
await github.rest.issues.getLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
name: label.name,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status !== 404) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.createLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
name: label.name,
|
|
||||||
color: label.color,
|
|
||||||
description: label.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
|
|
||||||
const desired = new Set(desiredLabels);
|
|
||||||
|
|
||||||
await ensureManagedLabels({ github, owner, repo });
|
|
||||||
|
|
||||||
for (const labelName of MANAGED_LABEL_NAMES) {
|
|
||||||
if (desired.has(labelName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.issues.removeLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
name: labelName,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status !== 404) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desired.size > 0) {
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
labels: [...desired],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findPolicyComment({ github, owner, repo, issueNumber }) {
|
|
||||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return comments.find(
|
|
||||||
(comment) =>
|
|
||||||
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
|
|
||||||
const existingComment = await findPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingComment == null) {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: existingComment.id,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
|
|
||||||
const existingComment = await findPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingComment == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.deleteComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: existingComment.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestMaintainerReview({ github, owner, repo, pr }) {
|
|
||||||
if (pr.user.login === REVIEWER_LOGIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.pulls.requestReviewers({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: pr.number,
|
|
||||||
reviewers: [REVIEWER_LOGIN],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 422) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkPullRequest({
|
|
||||||
github,
|
|
||||||
core,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pullNumber,
|
|
||||||
dryRun,
|
|
||||||
skipCreatedBefore,
|
|
||||||
}) {
|
|
||||||
const response = await github.rest.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: pullNumber,
|
|
||||||
});
|
|
||||||
const pr = response.data;
|
|
||||||
const issueNumber = pr.number;
|
|
||||||
|
|
||||||
if (
|
|
||||||
skipCreatedBefore != null &&
|
|
||||||
wasCreatedBefore(pr.created_at, skipCreatedBefore)
|
|
||||||
) {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pr.draft) {
|
|
||||||
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: "draft",
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
|
|
||||||
);
|
|
||||||
if (!dryRun) {
|
|
||||||
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
|
|
||||||
await deletePolicyComment({ github, owner, repo, issueNumber });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `maintainer @${pr.user.login}`,
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysis = analyzePullRequest(pr);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
const summary = summarizeResult({ pr, analysis });
|
|
||||||
core.notice(
|
|
||||||
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: analysis.blockers.length > 0,
|
|
||||||
number: pr.number,
|
|
||||||
summary,
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncLabels({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
desiredLabels: analysis.desiredLabels,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (analysis.blockers.length > 0) {
|
|
||||||
await upsertPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
body: buildPolicyComment(analysis),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
blocked: true,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({ pr, analysis }),
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
body: buildPolicyComment(analysis),
|
|
||||||
});
|
|
||||||
await requestMaintainerReview({ github, owner, repo, pr });
|
|
||||||
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({ pr, analysis }),
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listOpenPullRequests({ github, owner, repo }) {
|
|
||||||
return github.paginate(github.rest.pulls.list, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
state: "open",
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getManualPullRequestNumbers({ context, core }) {
|
|
||||||
const value = String(context.payload.inputs?.pr || "all").trim();
|
|
||||||
|
|
||||||
if (value.toLowerCase() === "all") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pullNumber = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
|
||||||
core.setFailed('The "pr" input must be "all" or a positive PR number.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [pullNumber];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run({ github, context, core }) {
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const payloadPr = context.payload.pull_request;
|
|
||||||
const dryRunInput = context.payload.inputs?.dry_run;
|
|
||||||
const dryRun =
|
|
||||||
context.eventName === "workflow_dispatch" &&
|
|
||||||
dryRunInput !== false &&
|
|
||||||
dryRunInput !== "false";
|
|
||||||
const skipCreatedBefore =
|
|
||||||
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
|
|
||||||
let pullNumbers;
|
|
||||||
|
|
||||||
if (payloadPr != null) {
|
|
||||||
pullNumbers = [payloadPr.number];
|
|
||||||
} else {
|
|
||||||
pullNumbers = getManualPullRequestNumbers({ context, core });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pullNumbers?.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pullRequests =
|
|
||||||
pullNumbers == null
|
|
||||||
? await listOpenPullRequests({ github, owner, repo })
|
|
||||||
: pullNumbers.map((number) => ({ number }));
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
core.notice(
|
|
||||||
`Running contribution policy in dry-run mode for ${
|
|
||||||
pullNumbers == null
|
|
||||||
? "all open PRs"
|
|
||||||
: pullNumbers.map((number) => `#${number}`).join(", ")
|
|
||||||
}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pr of pullRequests) {
|
|
||||||
results.push(
|
|
||||||
await checkPullRequest({
|
|
||||||
github,
|
|
||||||
core,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pullNumber: pr.number,
|
|
||||||
dryRun,
|
|
||||||
skipCreatedBefore,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await core.summary
|
|
||||||
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
|
|
||||||
.addTable([
|
|
||||||
[
|
|
||||||
{ data: "PR", header: true },
|
|
||||||
{ data: "Title", header: true },
|
|
||||||
{ data: "Status", header: true },
|
|
||||||
{ data: "Labels", header: true },
|
|
||||||
{ data: "Details", header: true },
|
|
||||||
{ data: "Comment", header: true },
|
|
||||||
],
|
|
||||||
...results.map((result) => [
|
|
||||||
result.summary.prLink,
|
|
||||||
result.summary.title,
|
|
||||||
result.summary.status,
|
|
||||||
result.summary.labels,
|
|
||||||
result.summary.details,
|
|
||||||
result.summary.comment,
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
.write();
|
|
||||||
|
|
||||||
const blockedPullRequests = results.filter((result) => result.blocked);
|
|
||||||
|
|
||||||
if (blockedPullRequests.length > 0) {
|
|
||||||
if (dryRun) {
|
|
||||||
core.warning(
|
|
||||||
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
|
|
||||||
.map((result) => `#${result.number}`)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setFailed(
|
|
||||||
`Contribution policy failed for PR(s): ${blockedPullRequests
|
|
||||||
.map((result) => `#${result.number}`)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
analyzePullRequest,
|
|
||||||
run,
|
|
||||||
};
|
|
||||||
@@ -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 });
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-9
@@ -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" }
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s 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 you’re 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;
|
|
||||||
}
|
|
||||||
@@ -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("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
|
||||||
{c.name}
|
|
||||||
</TableCell>
|
|
||||||
<TruncatedWideTableCell className="min-w-40">
|
|
||||||
{c.value}
|
|
||||||
</TruncatedWideTableCell>
|
|
||||||
<TableCell>{cookieDomain(c)}</TableCell>
|
|
||||||
<TableCell>{c.path}</TableCell>
|
|
||||||
<TableCell>{cookieExpires(c)}</TableCell>
|
|
||||||
<TableCell>{cookieSize(c)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.httpOnly ? "check" : "x"}
|
|
||||||
className={classNames(!c.httpOnly && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.secure ? "check" : "x"}
|
|
||||||
className={classNames(!c.secure && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{c.sameSite}</TableCell>
|
|
||||||
<TableCell className="rounded-r pr-2">
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Delete"
|
|
||||||
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (isSelected) {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
}
|
|
||||||
if (editingCookieKey === key) {
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
}
|
|
||||||
void patchModel(cookieJar, {
|
|
||||||
cookies: cookieJar.cookies.filter(
|
|
||||||
(c2: Cookie) => cookieKey(c2) !== key,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
detailCookie == null
|
|
||||||
? null
|
|
||||||
: ({ style }) => (
|
|
||||||
<CookieDetailsPane
|
|
||||||
formRef={editorFormRef}
|
|
||||||
isEditing={isEditingCookie}
|
|
||||||
onSubmit={handleSaveCookie}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<EventDetailHeader
|
|
||||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
|
||||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
|
||||||
actions={
|
|
||||||
isEditingCookie
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "save",
|
|
||||||
label: isCreatingCookie ? "Create" : "Save",
|
|
||||||
onClick: () => editorFormRef.current?.requestSubmit(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancel",
|
|
||||||
label: "Cancel",
|
|
||||||
onClick: handleCancelEdit,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: "edit",
|
|
||||||
label: "Edit",
|
|
||||||
onClick: handleEditCookie,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
onClose={handleCloseDetails}
|
|
||||||
/>
|
|
||||||
{isEditingCookie ? (
|
|
||||||
<CookieEditor
|
|
||||||
cookie={detailCookie}
|
|
||||||
expiresInputValue={draftExpiresInput}
|
|
||||||
onChange={setDraftCookie}
|
|
||||||
onExpiresInputChange={setDraftExpiresInput}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CookieDetails cookie={detailCookie} />
|
|
||||||
)}
|
|
||||||
</CookieDetailsPane>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function CookieDetailsPane({
|
|
||||||
children,
|
|
||||||
formRef,
|
|
||||||
isEditing,
|
|
||||||
onSubmit,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
formRef: RefObject<HTMLFormElement | null>;
|
|
||||||
isEditing: boolean;
|
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
||||||
style: CSSProperties;
|
|
||||||
}) {
|
|
||||||
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
|
|
||||||
{children}
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
// );
|
||||||
|
} else {
|
||||||
return (
|
tableBody = cookieJar?.cookies.map((c: Cookie) => (
|
||||||
<div style={style} className={className}>
|
<tr key={JSON.stringify(c)}>
|
||||||
{children}
|
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||||
</div>
|
{cookieDomain(c)}
|
||||||
);
|
</td>
|
||||||
}
|
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
||||||
|
{c.raw_cookie}
|
||||||
CookieDialog.show = (cookieJarId: string | null) => {
|
</td>
|
||||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
<td className="max-w-0 w-10">
|
||||||
if (cookieJar == null) {
|
<IconButton
|
||||||
showAlert({
|
icon="trash"
|
||||||
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="Delete"
|
||||||
{ label: "n/a", value: "" },
|
className="ml-auto"
|
||||||
{ label: "Lax", value: "Lax" },
|
onClick={async () =>
|
||||||
{ label: "Strict", value: "Strict" },
|
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJar.id, (prev) => ({
|
||||||
{ label: "None", value: "None" },
|
...prev,
|
||||||
]}
|
cookies: prev.cookies.filter((c2: Cookie) => c2 !== c),
|
||||||
onChange={(sameSite) =>
|
}))
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CookieKeyValueRow>
|
</td>
|
||||||
</KeyValueRows>
|
</tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-2">
|
||||||
|
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 text-left">Domain</th>
|
||||||
|
<th className="py-2 text-left pl-4">Cookie</th>
|
||||||
|
<th className="py-2 pl-4 w-10">
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
size="xs"
|
||||||
|
iconSize="sm"
|
||||||
|
title="Add Cookie"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={onAddCookie}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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{" "}
|
</p>
|
||||||
<strong className="font-semibold text-text-subtle">
|
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||||
{resolvedModelName(model)}
|
|
||||||
</strong>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<AuthenticationTypeDropdown model={model} />
|
|
||||||
<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>•</span>
|
<span>•</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={
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 •{" "}
|
||||||
|
<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">•</span>
|
|
||||||
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
|
|
||||||
<span className="text-text-subtlest">•</span>
|
|
||||||
<SizeTag
|
|
||||||
className="text-xs"
|
|
||||||
contentLength={r.contentLength ?? 0}
|
|
||||||
contentLengthCompressed={r.contentLengthCompressed}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
|
||||||
onSelect: () => onPinnedResponseId(r.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasRecentResponses && !hasShownRecentEmptyState) {
|
|
||||||
responseHistoryItems.push({
|
|
||||||
type: "content",
|
|
||||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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">→</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 •{" "}
|
||||||
|
<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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,168 +2,174 @@ 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">
|
<CargoFeature feature="updater">
|
||||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||||
</div>
|
<Select
|
||||||
<SettingsList className="space-y-8">
|
name="updateChannel"
|
||||||
<CargoFeature feature="updater">
|
label="Update Channel"
|
||||||
<SettingsSection title="Updates">
|
labelPosition="left"
|
||||||
<SettingRow
|
labelClassName="w-[14rem]"
|
||||||
title="Update Channel"
|
size="sm"
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
value={settings.updateChannel}
|
||||||
>
|
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
options={[
|
||||||
<ModelSettingSelectControl
|
{ label: "Stable", value: "stable" },
|
||||||
model={settings}
|
{ label: "Beta (more frequent)", value: "beta" },
|
||||||
modelKey="updateChannel"
|
]}
|
||||||
label="Update Channel"
|
/>
|
||||||
selectClassName="w-full!"
|
<IconButton
|
||||||
options={[
|
variant="border"
|
||||||
{ label: "Stable", value: "stable" },
|
size="sm"
|
||||||
{ label: "Beta", value: "beta" },
|
title="Check for updates"
|
||||||
]}
|
icon="refresh"
|
||||||
/>
|
spin={checkForUpdates.isPending}
|
||||||
<IconButton
|
onClick={() => checkForUpdates.mutateAsync()}
|
||||||
variant="border"
|
/>
|
||||||
size="sm"
|
</div>
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
<Select
|
||||||
title="Update Behavior"
|
name="autoupdate"
|
||||||
description="Choose whether updates are installed automatically or manually."
|
value={settings.autoupdate ? "auto" : "manual"}
|
||||||
name="autoupdate"
|
label="Update Behavior"
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
labelPosition="left"
|
||||||
onChange={(v) =>
|
size="sm"
|
||||||
patchModel(settings, { autoupdate: v === "auto" })
|
labelClassName="w-[14rem]"
|
||||||
}
|
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Automatic", value: "auto" },
|
{ label: "Automatic", value: "auto" },
|
||||||
{ label: "Manual", value: "manual" },
|
{ label: "Manual", value: "manual" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
className="pl-2 mt-1 ml-[14rem]"
|
||||||
|
checked={settings.autoDownloadUpdates}
|
||||||
|
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="autoDownloadUpdates"
|
checked={settings.checkNotifications}
|
||||||
title="Automatically download updates"
|
title="Check for notifications"
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
help="Periodically ping Yaak servers to check for relevant notifications."
|
||||||
disabled={!settings.autoupdate}
|
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled
|
||||||
|
className="pl-2 mt-1 ml-[14rem]"
|
||||||
|
checked={false}
|
||||||
|
title="Send anonymous usage statistics"
|
||||||
|
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
||||||
|
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||||
|
/>
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
<Separator className="my-4" />
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
<Heading level={2}>
|
||||||
title="Send anonymous usage statistics"
|
Workspace{" "}
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
||||||
disabled
|
{workspace.name}
|
||||||
checked={false}
|
</div>
|
||||||
onChange={() => {}}
|
</Heading>
|
||||||
/>
|
<VStack className="mt-1 w-full" space={3}>
|
||||||
</SettingsSection>
|
<PlainInput
|
||||||
</CargoFeature>
|
required
|
||||||
|
size="sm"
|
||||||
|
name="requestTimeout"
|
||||||
|
label="Request Timeout (ms)"
|
||||||
|
labelClassName="w-[14rem]"
|
||||||
|
placeholder="0"
|
||||||
|
labelPosition="left"
|
||||||
|
defaultValue={`${workspace.settingRequestTimeout}`}
|
||||||
|
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||||
|
onChange={(v) =>
|
||||||
|
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
<Checkbox
|
||||||
<DismissibleBanner
|
checked={workspace.settingValidateCertificates}
|
||||||
id="workspace-settings-moved-2026-06-30"
|
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||||
color="info"
|
title="Validate TLS certificates"
|
||||||
className="p-4 max-w-xl mx-auto"
|
onChange={(settingValidateCertificates) =>
|
||||||
>
|
patchModel(workspace, { settingValidateCertificates })
|
||||||
<p>
|
}
|
||||||
Workspace specific settings have moved to{" "}
|
/>
|
||||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
|
||||||
menu.
|
|
||||||
</p>
|
|
||||||
</DismissibleBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
<Checkbox
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
checked={workspace.settingFollowRedirects}
|
||||||
<SettingValue value={appInfo.version} />
|
title="Follow redirects"
|
||||||
</SettingRow>
|
onChange={(settingFollowRedirects) =>
|
||||||
<SettingRow
|
patchModel(workspace, {
|
||||||
title="Data Directory"
|
settingFollowRedirects,
|
||||||
description="Where Yaak stores application data."
|
})
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
}
|
||||||
>
|
/>
|
||||||
<SettingValue
|
</VStack>
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
<Separator className="my-4" />
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
<Heading level={2}>App Info</Heading>
|
||||||
icon: "folder_open",
|
<KeyValueRows>
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||||
},
|
<KeyValueRow
|
||||||
]}
|
label="Data Directory"
|
||||||
|
rightSlot={
|
||||||
|
<IconButton
|
||||||
|
title={revealInFinderText}
|
||||||
|
icon="folder_open"
|
||||||
|
size="2xs"
|
||||||
|
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
}
|
||||||
<SettingRow
|
>
|
||||||
title="Logs Directory"
|
{appInfo.appDataDir}
|
||||||
description="Where Yaak writes application logs."
|
</KeyValueRow>
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
<KeyValueRow
|
||||||
>
|
label="Logs Directory"
|
||||||
<SettingValue
|
rightSlot={
|
||||||
value={appInfo.appLogDir}
|
<IconButton
|
||||||
actions={[
|
title={revealInFinderText}
|
||||||
{
|
icon="folder_open"
|
||||||
title: revealInFinderText,
|
size="2xs"
|
||||||
icon: "folder_open",
|
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
}
|
||||||
</SettingsSection>
|
>
|
||||||
</SettingsList>
|
{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,172 +38,154 @@ 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">
|
name="switchWorkspaceBehavior"
|
||||||
<SettingRowSelect
|
label="Open workspace behavior"
|
||||||
title="Open workspace behavior"
|
size="sm"
|
||||||
description="Choose what happens when opening another workspace."
|
help="When opening a workspace, should it open in the current window or a new window?"
|
||||||
name="switchWorkspaceBehavior"
|
value={
|
||||||
value={
|
settings.openWorkspaceNewWindow === true
|
||||||
settings.openWorkspaceNewWindow === true
|
? "new"
|
||||||
? "new"
|
: settings.openWorkspaceNewWindow === false
|
||||||
: settings.openWorkspaceNewWindow === false
|
? "current"
|
||||||
? "current"
|
: "ask"
|
||||||
: "ask"
|
}
|
||||||
}
|
onChange={async (v) => {
|
||||||
onChange={async (v) => {
|
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
}}
|
||||||
}}
|
options={[
|
||||||
|
{ label: "Always ask", value: "ask" },
|
||||||
|
{ label: "Open in current window", value: "current" },
|
||||||
|
{ label: "Open in new window", value: "new" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<HStack space={2} alignItems="end">
|
||||||
|
{fonts.data && (
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
name="uiFont"
|
||||||
|
label="Interface font"
|
||||||
|
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Always ask", value: "ask" },
|
{ label: "System default", value: NULL_FONT_VALUE },
|
||||||
{ label: "Open in current window", value: "current" },
|
...(fonts.data.uiFonts.map((f) => ({
|
||||||
{ label: "Open in new window", value: "new" },
|
label: f,
|
||||||
|
value: f,
|
||||||
|
})) ?? []),
|
||||||
|
// Some people like monospace fonts for the UI
|
||||||
|
...(fonts.data.editorFonts.map((f) => ({
|
||||||
|
label: f,
|
||||||
|
value: f,
|
||||||
|
})) ?? []),
|
||||||
]}
|
]}
|
||||||
|
onChange={async (v) => {
|
||||||
|
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||||
|
await patchModel(settings, { interfaceFont });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
)}
|
||||||
|
<Select
|
||||||
<SettingsSection title="Fonts">
|
hideLabel
|
||||||
<SettingRow
|
size="sm"
|
||||||
title="Interface font"
|
name="interfaceFontSize"
|
||||||
description="Font used for Yaak interface controls."
|
label="Interface Font Size"
|
||||||
controlClassName="gap-1"
|
defaultValue="14"
|
||||||
>
|
value={`${settings.interfaceFontSize}`}
|
||||||
{fonts.data && (
|
options={fontSizeOptions}
|
||||||
<SettingSelectControl
|
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||||
name="uiFont"
|
/>
|
||||||
label="Interface font"
|
</HStack>
|
||||||
selectClassName="w-72!"
|
<HStack space={2} alignItems="end">
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
{fonts.data && (
|
||||||
defaultValue={NULL_FONT_VALUE}
|
<Select
|
||||||
options={[
|
size="sm"
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
name="editorFont"
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
label="Editor font"
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||||
]}
|
options={[
|
||||||
onChange={async (v) => {
|
{ label: "System default", value: NULL_FONT_VALUE },
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
...(fonts.data.editorFonts.map((f) => ({
|
||||||
await patchModel(settings, { interfaceFont });
|
label: f,
|
||||||
}}
|
value: f,
|
||||||
/>
|
})) ?? []),
|
||||||
)}
|
]}
|
||||||
<SettingSelectControl
|
onChange={async (v) => {
|
||||||
name="interfaceFontSize"
|
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||||
label="Interface Font Size"
|
await patchModel(settings, { editorFont });
|
||||||
selectClassName="w-20!"
|
}}
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
defaultValue="14"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Editor font"
|
|
||||||
description="Font used in request and response editors."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
selectClassName="w-72!"
|
|
||||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
selectClassName="w-20!"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
defaultValue="12"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, {
|
|
||||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorKeymap"
|
|
||||||
title="Editor keymap"
|
|
||||||
description="Keyboard shortcut preset used by text editors."
|
|
||||||
options={keymaps}
|
|
||||||
/>
|
/>
|
||||||
<ModelSettingRowBoolean
|
)}
|
||||||
model={settings}
|
<Select
|
||||||
modelKey="editorSoftWrap"
|
hideLabel
|
||||||
title="Wrap editor lines"
|
size="sm"
|
||||||
description="Wrap long lines in request and response editors."
|
name="editorFontSize"
|
||||||
/>
|
label="Editor Font Size"
|
||||||
<ModelSettingRowBoolean
|
defaultValue="12"
|
||||||
model={settings}
|
value={`${settings.editorFontSize}`}
|
||||||
modelKey="coloredMethods"
|
options={fontSizeOptions}
|
||||||
title="Colorize request methods"
|
onChange={(v) =>
|
||||||
description="Use method-specific colors for HTTP request methods."
|
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
||||||
/>
|
}
|
||||||
</SettingsSection>
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Select
|
||||||
|
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
||||||
|
size="sm"
|
||||||
|
name="editorKeymap"
|
||||||
|
label="Editor keymap"
|
||||||
|
value={`${settings.editorKeymap}`}
|
||||||
|
options={keymaps}
|
||||||
|
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={settings.editorSoftWrap}
|
||||||
|
title="Wrap editor lines"
|
||||||
|
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={settings.coloredMethods}
|
||||||
|
title="Colorize request methods"
|
||||||
|
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
||||||
|
/>
|
||||||
|
<CargoFeature feature="license">
|
||||||
|
<LicenseSettings settings={settings} />
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
<NativeTitlebarSetting settings={settings} />
|
||||||
<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">
|
{type() !== "macos" && (
|
||||||
<LicenseSettings settings={settings} />
|
<Checkbox
|
||||||
</CargoFeature>
|
checked={settings.hideWindowControls}
|
||||||
</SettingsList>
|
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,42 +205,37 @@ 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"
|
onChange={async (hideLicenseBadge) => {
|
||||||
description="Hide the personal-use badge from the interface."
|
if (hideLicenseBadge) {
|
||||||
onChange={async (hideLicenseBadge) => {
|
const confirmed = await showConfirm({
|
||||||
if (hideLicenseBadge) {
|
id: "hide-license-badge",
|
||||||
const confirmed = await showConfirm({
|
title: "Confirm Personal Use",
|
||||||
id: "hide-license-badge",
|
confirmText: "Confirm",
|
||||||
title: "Confirm Personal Use",
|
description: (
|
||||||
confirmText: "Confirm",
|
<VStack space={3}>
|
||||||
description: (
|
<p>Hey there 👋🏼</p>
|
||||||
<VStack space={3}>
|
<p>
|
||||||
<p>Hey there 👋🏼</p>
|
Yaak is free for personal projects and learning.{" "}
|
||||||
<p>
|
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||||
Yaak is free for personal projects and learning.{" "}
|
</p>
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
<p>
|
||||||
</p>
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
<p>
|
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
</p>
|
||||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
</VStack>
|
||||||
Purchase a License →
|
),
|
||||||
</Link>
|
requireTyping: "Personal Use",
|
||||||
</p>
|
color: "info",
|
||||||
</VStack>
|
});
|
||||||
),
|
if (!confirmed) {
|
||||||
requireTyping: "Personal Use",
|
return; // Cancel
|
||||||
color: "info",
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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,147 +18,188 @@ 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">
|
name="proxy"
|
||||||
<SettingsSection title="Proxy">
|
label="Proxy"
|
||||||
<SettingRowSelect
|
hideLabel
|
||||||
title="Proxy"
|
size="sm"
|
||||||
description="Choose how Yaak should discover or use proxy settings."
|
value={settings.proxy?.type ?? "automatic"}
|
||||||
name="proxy"
|
onChange={async (v) => {
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
if (v === "automatic") {
|
||||||
onChange={async (v) => {
|
await patchModel(settings, { proxy: undefined });
|
||||||
if (v === "automatic") {
|
} else if (v === "enabled") {
|
||||||
await patchModel(settings, { proxy: undefined });
|
await patchModel(settings, {
|
||||||
} else if (v === "enabled") {
|
proxy: {
|
||||||
await patchModel(settings, { proxy });
|
disabled: false,
|
||||||
} else {
|
type: "enabled",
|
||||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
http: "",
|
||||||
}
|
https: "",
|
||||||
|
auth: { user: "", password: "" },
|
||||||
|
bypass: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await patchModel(settings, { proxy: { type: "disabled" } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: "Automatic proxy detection", value: "automatic" },
|
||||||
|
{ label: "Custom proxy configuration", value: "enabled" },
|
||||||
|
{ label: "No proxy", value: "disabled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{settings.proxy?.type === "enabled" && (
|
||||||
|
<VStack space={1.5}>
|
||||||
|
<Checkbox
|
||||||
|
className="my-3"
|
||||||
|
checked={!settings.proxy.disabled}
|
||||||
|
title="Enable proxy"
|
||||||
|
help="Use this to temporarily disable the proxy without losing the configuration"
|
||||||
|
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 },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
options={[
|
|
||||||
{ label: "Automatic proxy detection", value: "automatic" },
|
|
||||||
{ label: "Custom proxy configuration", value: "enabled" },
|
|
||||||
{ label: "No proxy", value: "disabled" },
|
|
||||||
]}
|
|
||||||
selectClassName="w-64!"
|
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
<HStack space={1.5}>
|
||||||
|
<PlainInput
|
||||||
{settings.proxy?.type === "enabled" && (
|
size="sm"
|
||||||
<>
|
label={
|
||||||
<SettingsSection title="Custom Proxy">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={!settings.proxy.disabled}
|
|
||||||
title="Enable proxy"
|
|
||||||
description="Temporarily disable the proxy without losing the configuration."
|
|
||||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttp"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for unencrypted HTTP traffic."
|
|
||||||
value={settings.proxy.http}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(http) => patchProxy({ http })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttps"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for HTTPS traffic."
|
|
||||||
value={settings.proxy.https}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(https) => patchProxy({ https })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyBypass"
|
|
||||||
title="Proxy Bypass"
|
|
||||||
description="Comma-separated list of hosts that should bypass the proxy."
|
|
||||||
value={settings.proxy.bypass}
|
|
||||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
|
||||||
inputWidthClassName="w-96!"
|
|
||||||
onChange={(bypass) => patchProxy({ bypass })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Authentication">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.proxy.auth != null}
|
|
||||||
title="Enable authentication"
|
|
||||||
description="Send proxy credentials with proxied requests."
|
|
||||||
onChange={(enabled) =>
|
|
||||||
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.proxy.auth != null && (
|
|
||||||
<>
|
<>
|
||||||
<SettingRowText
|
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||||
required
|
|
||||||
name="proxyUser"
|
|
||||||
title="User"
|
|
||||||
description="Username for proxy authentication."
|
|
||||||
value={settings.proxy.auth.user}
|
|
||||||
placeholder="myUser"
|
|
||||||
onChange={(user) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user,
|
|
||||||
password:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.password ?? "")
|
|
||||||
: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyPassword"
|
|
||||||
title="Password"
|
|
||||||
description="Password for proxy authentication."
|
|
||||||
value={settings.proxy.auth.password}
|
|
||||||
placeholder="s3cretPassw0rd"
|
|
||||||
type="password"
|
|
||||||
onChange={(password) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.user ?? "")
|
|
||||||
: "",
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
</SettingsSection>
|
placeholder="localhost:9090"
|
||||||
</>
|
defaultValue={settings.proxy?.http}
|
||||||
)}
|
onChange={async (http) => {
|
||||||
</SettingsList>
|
const { proxy } = settings;
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: {
|
||||||
|
type: "enabled",
|
||||||
|
http,
|
||||||
|
https,
|
||||||
|
auth,
|
||||||
|
disabled,
|
||||||
|
bypass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PlainInput
|
||||||
|
size="sm"
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placeholder="localhost:9090"
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<Checkbox
|
||||||
|
checked={settings.proxy.auth != null}
|
||||||
|
title="Enable authentication"
|
||||||
|
onChange={async (enabled) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const auth = enabled ? { user: "", password: "" } : null;
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{settings.proxy.auth != null && (
|
||||||
|
<HStack space={1.5}>
|
||||||
|
<PlainInput
|
||||||
|
required
|
||||||
|
size="sm"
|
||||||
|
label="User"
|
||||||
|
placeholder="myUser"
|
||||||
|
defaultValue={settings.proxy.auth.user}
|
||||||
|
onChange={async (user) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
||||||
|
const auth = { user, password };
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PlainInput
|
||||||
|
size="sm"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="s3cretPassw0rd"
|
||||||
|
defaultValue={settings.proxy.auth.password}
|
||||||
|
onChange={async (password) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||||
|
const auth = { user, password };
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{settings.proxy.type === "enabled" && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<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 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</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,92 +77,96 @@ 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") && (
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
leftSlot={<Icon icon="sun" color="secondary" />}
|
||||||
|
name="lightTheme"
|
||||||
|
label="Light Theme"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
value={activeTheme.data.light.id}
|
||||||
|
options={lightThemes}
|
||||||
|
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||||
/>
|
/>
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
)}
|
||||||
<SettingRowSelect
|
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||||
name="lightTheme"
|
<Select
|
||||||
title="Light theme"
|
hideLabel
|
||||||
description="Theme used when Yaak is in light mode."
|
name="darkTheme"
|
||||||
value={activeTheme.data.light.id}
|
className="flex-1"
|
||||||
options={lightThemes}
|
label="Dark Theme"
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||||
/>
|
size="sm"
|
||||||
)}
|
value={activeTheme.data.dark.id}
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
options={darkThemes}
|
||||||
<SettingRowSelect
|
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||||
name="darkTheme"
|
/>
|
||||||
title="Dark theme"
|
)}
|
||||||
description="Theme used when Yaak is in dark mode."
|
</HStack>
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
<VStack
|
||||||
<VStack
|
space={3}
|
||||||
space={3}
|
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
||||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm 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"} />
|
<strong>{activeTheme.data.active.label}</strong>
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
<em>(preview)</em>
|
||||||
<em>(preview)</em>
|
</HStack>
|
||||||
</HStack>
|
<HStack space={1.5} className="w-full">
|
||||||
<HStack space={1.5} className="w-full">
|
{buttonColors.map((c, i) => (
|
||||||
{buttonColors.map((c, i) => (
|
<IconButton
|
||||||
<IconButton
|
key={c}
|
||||||
key={c}
|
color={c}
|
||||||
color={c}
|
size="2xs"
|
||||||
size="2xs"
|
iconSize="xs"
|
||||||
iconSize="xs"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconClassName="text"
|
||||||
iconClassName="text"
|
title={`${c}`}
|
||||||
title={`${c}`}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
{buttonColors.map((c, i) => (
|
||||||
{buttonColors.map((c, i) => (
|
<IconButton
|
||||||
<IconButton
|
key={c}
|
||||||
key={c}
|
color={c}
|
||||||
color={c}
|
variant="border"
|
||||||
variant="border"
|
size="2xs"
|
||||||
size="2xs"
|
iconSize="xs"
|
||||||
iconSize="xs"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconClassName="text"
|
||||||
iconClassName="text"
|
title={`${c}`}
|
||||||
title={`${c}`}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</HStack>
|
||||||
</HStack>
|
<Suspense>
|
||||||
<Suspense>
|
<Editor
|
||||||
<Editor
|
defaultValue={[
|
||||||
defaultValue={[
|
"let foo = { // Demo code editor",
|
||||||
"let foo = { // Demo code editor",
|
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
" baz: [1, 10.2, null, false, true],",
|
||||||
" baz: [1, 10.2, null, false, true],",
|
"};",
|
||||||
"};",
|
].join("\n")}
|
||||||
].join("\n")}
|
heightMode="auto"
|
||||||
heightMode="auto"
|
language="javascript"
|
||||||
language="javascript"
|
stateKey={null}
|
||||||
stateKey={null}
|
/>
|
||||||
/>
|
</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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +170,7 @@ function WebsocketEventDetail({
|
|||||||
? "Connection Closed"
|
? "Connection Closed"
|
||||||
: event.messageType === "open"
|
: event.messageType === "open"
|
||||||
? "Connection Open"
|
? "Connection Open"
|
||||||
: event.messageType === "error"
|
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||||
? "WebSocket Error"
|
|
||||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
|
||||||
|
|
||||||
const actions: EventDetailAction[] =
|
const actions: EventDetailAction[] =
|
||||||
message !== ""
|
message !== ""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
{children}
|
||||||
<div
|
<HStack space={1.5}>
|
||||||
className={classNames(
|
{actions?.map((a) => (
|
||||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
<Button
|
||||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
key={a.label}
|
||||||
)}
|
variant="border"
|
||||||
|
color={a.color ?? props.color}
|
||||||
|
size="xs"
|
||||||
|
onClick={a.onClick}
|
||||||
|
title={a.label}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="border"
|
||||||
|
color={props.color}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setDismissed((d) => !d)}
|
||||||
|
title="Dismiss message"
|
||||||
>
|
>
|
||||||
{children}
|
Dismiss
|
||||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
</Button>
|
||||||
<Button
|
</HStack>
|
||||||
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) => (
|
|
||||||
<Button
|
|
||||||
key={a.label}
|
|
||||||
variant={a.variant ?? "border"}
|
|
||||||
color={a.color ?? props.color}
|
|
||||||
size={actionSize}
|
|
||||||
onClick={(event) => {
|
|
||||||
stopParentClick(event);
|
|
||||||
a.onClick();
|
|
||||||
}}
|
|
||||||
title={a.label}
|
|
||||||
>
|
|
||||||
{a.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!OY!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({
|
||||||
if (node.name !== "Placeholder") return;
|
enter(node) {
|
||||||
const globalFrom = node.from;
|
if (node.name !== "Placeholder") return;
|
||||||
const globalTo = node.to;
|
const globalFrom = innerTree.node.from + node.from;
|
||||||
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
const globalTo = innerTree.node.from + node.to;
|
||||||
const onClick = () => onClickPathParameter(rawText);
|
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
||||||
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
const onClick = () => onClickPathParameter(rawText);
|
||||||
const deco = Decoration.replace({ widget, inclusive: false });
|
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
||||||
widgets.push(deco.range(globalFrom, globalTo));
|
const deco = Decoration.replace({ widget, inclusive: false });
|
||||||
|
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
Reference in New Issue
Block a user