mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-06 09:57:15 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab785b18a4 | ||
|
|
947e3f2e97 | ||
|
|
8b1f5e807f |
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
description: Generate formatted release notes for Yaak releases
|
||||||
|
allowed-tools: Bash(git tag:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. Identifies the version tag and previous version
|
||||||
|
2. Retrieves all commits between versions
|
||||||
|
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
|
||||||
|
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
|
||||||
|
3. Fetches PR descriptions for linked issues to find:
|
||||||
|
- Feedback URLs (feedback.yaak.app)
|
||||||
|
- Additional context and descriptions
|
||||||
|
- Installation links for plugins
|
||||||
|
4. Formats the release notes using the standard Yaak format:
|
||||||
|
- Changelog badge at the top
|
||||||
|
- Bulleted list of changes with PR links
|
||||||
|
- Feedback links where available
|
||||||
|
- Full changelog comparison link at the bottom
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
The skill generates markdown-formatted release notes following this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[](https://yaak.app/changelog/VERSION)
|
||||||
|
|
||||||
|
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
|
||||||
|
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
|
||||||
|
- A simple item that doesn't have a feedback or PR link
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
||||||
|
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
||||||
|
**IMPORTANT**: 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.
|
||||||
|
|
||||||
|
## After Generating Release Notes
|
||||||
|
|
||||||
|
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".
|
||||||
@@ -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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
dist
|
|
||||||
**/dist
|
|
||||||
target
|
|
||||||
**/target
|
|
||||||
.claude
|
|
||||||
vendored
|
|
||||||
**/vendored
|
|
||||||
*.log
|
|
||||||
.git
|
|
||||||
@@ -4,16 +4,13 @@
|
|||||||
|
|
||||||
## Submission
|
## Submission
|
||||||
|
|
||||||
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
|
- [ ] This PR is a bug fix or small-scope improvement.
|
||||||
|
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
|
||||||
- [ ] This PR is a bug fix.
|
|
||||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
|
||||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
- [ ] I tested this change locally.
|
- [ ] I tested this change locally.
|
||||||
- [ ] I added or updated tests, or tests are not reasonable for this change.
|
- [ ] I added or updated tests when reasonable.
|
||||||
- [ ] I added screenshots or recordings, or this change does not affect the UI.
|
|
||||||
|
|
||||||
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,881 +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 MIN_AUTOMATIC_PR_NUMBER = 494;
|
|
||||||
|
|
||||||
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)),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Each checkbox lists its current label first, followed by legacy labels still
|
|
||||||
// accepted from PRs opened against older versions of the template.
|
|
||||||
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, or tests are not reasonable for this change.",
|
|
||||||
"I added or updated tests when reasonable.",
|
|
||||||
],
|
|
||||||
screenshotsAdded: [
|
|
||||||
"I added screenshots or recordings, or this change does not affect the UI.",
|
|
||||||
"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, labels) {
|
|
||||||
const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
|
|
||||||
|
|
||||||
for (const line of body.split("\n")) {
|
|
||||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
|
||||||
|
|
||||||
if (match == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
|
|
||||||
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 (labelNames.has(LABELS.explicitPermission.name)) {
|
|
||||||
return {
|
|
||||||
blockers: [],
|
|
||||||
changedFiles,
|
|
||||||
desiredLabels: [LABELS.explicitPermission.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, or that tests are not reasonable for this change. Check the box either way.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (states.screenshotsAdded !== true) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
minimumAutomaticPullNumber,
|
|
||||||
}) {
|
|
||||||
const response = await github.rest.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: pullNumber,
|
|
||||||
});
|
|
||||||
const pr = response.data;
|
|
||||||
const issueNumber = pr.number;
|
|
||||||
|
|
||||||
if (pr.user.type === "Bot") {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `bot @${pr.user.login}`,
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
minimumAutomaticPullNumber != null &&
|
|
||||||
pr.number < minimumAutomaticPullNumber
|
|
||||||
) {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
|
|
||||||
}),
|
|
||||||
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 minimumAutomaticPullNumber =
|
|
||||||
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
|
|
||||||
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,
|
|
||||||
minimumAutomaticPullNumber,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
@@ -30,8 +30,4 @@ jobs:
|
|||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --features yaak-app-client/wry
|
run: cargo test --all
|
||||||
- name: OpenAPI import round-trip
|
|
||||||
run: |
|
|
||||||
cargo build -p yaak-cli
|
|
||||||
node plugins/importer-openapi/tests/roundtrip.mjs
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||||
|
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||||
|
|
||||||
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||||
@@ -1,48 +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:
|
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
name: Check contribution policy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout policy script
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
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,21 +1,17 @@
|
|||||||
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
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-flathub:
|
update-flathub:
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
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
|
||||||
@@ -35,7 +31,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install source generators
|
- name: Install source generators
|
||||||
run: |
|
run: |
|
||||||
@@ -43,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
|
||||||
@@ -52,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
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-npm:
|
publish-npm:
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
name: Publish @yaakapp/api
|
name: Publish @yaakapp/api
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -15,62 +14,38 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
||||||
args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: "--target aarch64-apple-darwin"
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "macos"
|
os: "macos"
|
||||||
runtime: "wry"
|
|
||||||
targets: "aarch64-apple-darwin"
|
targets: "aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel-based Macs.
|
- platform: "macos-latest" # for Intel-based Macs.
|
||||||
args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: "--target x86_64-apple-darwin"
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "macos"
|
os: "macos"
|
||||||
runtime: "wry"
|
|
||||||
targets: "x86_64-apple-darwin"
|
targets: "x86_64-apple-darwin"
|
||||||
- platform: "ubuntu-22.04"
|
- platform: "ubuntu-22.04"
|
||||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: ""
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "ubuntu"
|
os: "ubuntu"
|
||||||
runtime: "wry"
|
|
||||||
targets: ""
|
targets: ""
|
||||||
- platform: "ubuntu-22.04-arm"
|
- platform: "ubuntu-22.04-arm"
|
||||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: ""
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "ubuntu"
|
os: "ubuntu"
|
||||||
runtime: "wry"
|
|
||||||
targets: ""
|
|
||||||
- platform: "ubuntu-22.04"
|
|
||||||
args: >-
|
|
||||||
--bundles deb
|
|
||||||
--config ./tauri.release.conf.json
|
|
||||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
|
||||||
yaak_arch: "x64"
|
|
||||||
os: "ubuntu"
|
|
||||||
runtime: "cef"
|
|
||||||
targets: ""
|
|
||||||
- platform: "ubuntu-22.04-arm"
|
|
||||||
args: >-
|
|
||||||
--bundles deb
|
|
||||||
--config ./tauri.release.conf.json
|
|
||||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
|
||||||
yaak_arch: "arm64"
|
|
||||||
os: "ubuntu"
|
|
||||||
runtime: "cef"
|
|
||||||
targets: ""
|
targets: ""
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: ""
|
||||||
yaak_arch: "x64"
|
yaak_arch: "x64"
|
||||||
os: "windows"
|
os: "windows"
|
||||||
runtime: "wry"
|
|
||||||
targets: ""
|
targets: ""
|
||||||
# Windows ARM64
|
# Windows ARM64
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
args: "--target aarch64-pc-windows-msvc"
|
||||||
yaak_arch: "arm64"
|
yaak_arch: "arm64"
|
||||||
os: "windows"
|
os: "windows"
|
||||||
runtime: "wry"
|
|
||||||
targets: "aarch64-pc-windows-msvc"
|
targets: "aarch64-pc-windows-msvc"
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout yaakapp/app
|
- name: Checkout yaakapp/app
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -91,30 +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' && matrix.runtime == 'cef'
|
|
||||||
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 libnss3 patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
|
||||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
|
||||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
|
||||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
|
||||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
|
||||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
|
||||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
|
||||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
|
||||||
chmod +x /tmp/llvm.sh
|
|
||||||
sudo /tmp/llvm.sh 18
|
|
||||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
|
||||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Install Protoc for plugin-runtime
|
- name: Install Protoc for plugin-runtime
|
||||||
uses: arduino/setup-protoc@v3
|
uses: arduino/setup-protoc@v3
|
||||||
@@ -142,7 +98,7 @@ jobs:
|
|||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
run: cargo test --all --exclude yaak-cli
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
run: npm run replace-version
|
run: npm run replace-version
|
||||||
@@ -194,30 +150,13 @@ 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: "<!-- generated-by-yaak-releases -->"
|
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
projectPath: ./crates-tauri/yaak-app-client
|
projectPath: ./crates-tauri/yaak-app-client
|
||||||
args: "${{ matrix.args }}"
|
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
|
||||||
|
|
||||||
- name: Build and upload CEF tarball from deb (Linux only)
|
|
||||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
|
|
||||||
version="${GITHUB_REF_NAME#v}"
|
|
||||||
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
|
|
||||||
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
|
|
||||||
rm -rf "$extract_dir"
|
|
||||||
mkdir -p "$extract_dir"
|
|
||||||
dpkg-deb -x "$deb" "$extract_dir"
|
|
||||||
tar -C "$extract_dir" -czf "$tarball" .
|
|
||||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
|
||||||
|
|
||||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||||
- name: Build and upload machine-wide installer (Windows only)
|
- name: Build and upload machine-wide installer (Windows only)
|
||||||
@@ -234,7 +173,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||||
Push-Location crates-tauri/yaak-app-client
|
Push-Location crates-tauri/yaak-app-client
|
||||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||||
Pop-Location
|
Pop-Location
|
||||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||||
$setupSig = "$($setup.FullName).sig"
|
$setupSig = "$($setup.FullName).sig"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Release CLI to NPM
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [v*]
|
tags: [yaak-cli-*]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -15,7 +15,6 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-vendored-assets:
|
prepare-vendored-assets:
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
name: Prepare vendored plugin assets
|
name: Prepare vendored plugin assets
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ jobs:
|
|||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
VERSION="$WORKFLOW_VERSION"
|
VERSION="$WORKFLOW_VERSION"
|
||||||
else
|
else
|
||||||
VERSION="${GITHUB_REF_NAME}"
|
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||||
fi
|
fi
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
echo "Building yaak version: $VERSION"
|
echo "Building yaak version: $VERSION"
|
||||||
@@ -176,7 +175,7 @@ jobs:
|
|||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
VERSION="$WORKFLOW_VERSION"
|
VERSION="$WORKFLOW_VERSION"
|
||||||
else
|
else
|
||||||
VERSION="${GITHUB_REF_NAME}"
|
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||||
fi
|
fi
|
||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
if [[ "$VERSION" == *-* ]]; then
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
name: Release web image
|
|
||||||
|
|
||||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
|
||||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
|
||||||
# joined into one multi-arch tag at the end.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [v*]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
name: Build ${{ matrix.platform }}
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-22.04
|
|
||||||
arch: amd64
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-22.04-arm
|
|
||||||
arch: arm64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile.web
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: |
|
|
||||||
mkdir -p "${{ runner.temp }}/digests"
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: digest-${{ matrix.arch }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish manifest
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/digests
|
|
||||||
pattern: digest-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
|
||||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
|
||||||
- name: Tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.IMAGE }}
|
|
||||||
flavor: latest=false
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
|
||||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
|
||||||
type=ref,event=branch
|
|
||||||
type=sha,format=short
|
|
||||||
|
|
||||||
- name: Create and push the manifest
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: Inspect
|
|
||||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
|
||||||
@@ -7,7 +7,6 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
if: github.repository == 'mountain-loop/yaak'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout 🛎️
|
- name: Checkout 🛎️
|
||||||
|
|||||||
@@ -58,4 +58,3 @@ flatpak/node-sources.json
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
**/bindings/**
|
||||||
|
**/routeTree.gen.ts
|
||||||
|
crates/yaak-templates/pkg/**
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
|
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
|
||||||
- Do not commit, push, or tag without explicit approval
|
- Do not commit, push, or tag without explicit approval
|
||||||
|
|||||||
+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
+193
-1491
File diff suppressed because it is too large
Load Diff
+2
-21
@@ -2,11 +2,9 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
"crates/yaak-commands",
|
|
||||||
# Common/foundation crates
|
# Common/foundation crates
|
||||||
"crates/common/yaak-database",
|
"crates/common/yaak-database",
|
||||||
"crates/common/yaak-rpc",
|
"crates/common/yaak-rpc",
|
||||||
"crates/common/yaak-rpc-schema",
|
|
||||||
# Shared crates (no Tauri dependency)
|
# Shared crates (no Tauri dependency)
|
||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
@@ -14,7 +12,6 @@ members = [
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-http",
|
"crates/yaak-http",
|
||||||
"crates/yaak-lifecycle",
|
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
@@ -22,13 +19,10 @@ members = [
|
|||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
"crates/yaak-tls",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-ws",
|
"crates/yaak-ws",
|
||||||
"crates/yaak-wasm",
|
|
||||||
"crates/yaak-api",
|
"crates/yaak-api",
|
||||||
"crates/yaak-proxy",
|
"crates/yaak-proxy",
|
||||||
# Proxy-specific crates
|
# Proxy-specific crates
|
||||||
"crates-proxy/yaak-proxy-lib",
|
"crates-proxy/yaak-proxy-lib",
|
||||||
# Server crates (the browser tier's hosted send executor)
|
|
||||||
"crates-server/yaak-web",
|
|
||||||
# CLI crates
|
# CLI crates
|
||||||
"crates-cli/yaak-cli",
|
"crates-cli/yaak-cli",
|
||||||
# Tauri-specific crates
|
# Tauri-specific crates
|
||||||
@@ -37,7 +31,6 @@ members = [
|
|||||||
"crates-tauri/yaak-fonts",
|
"crates-tauri/yaak-fonts",
|
||||||
"crates-tauri/yaak-license",
|
"crates-tauri/yaak-license",
|
||||||
"crates-tauri/yaak-mac-window",
|
"crates-tauri/yaak-mac-window",
|
||||||
"crates-tauri/yaak-system-appearance",
|
|
||||||
"crates-tauri/yaak-tauri-utils",
|
"crates-tauri/yaak-tauri-utils",
|
||||||
"crates-tauri/yaak-window",
|
"crates-tauri/yaak-window",
|
||||||
]
|
]
|
||||||
@@ -54,13 +47,9 @@ 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 = { version = "2.7.1", default-features = false }
|
tauri-plugin-dialog = "2.7.1"
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.5"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
@@ -69,18 +58,15 @@ ts-rs = "11.1.0"
|
|||||||
# Internal crates - common/foundation
|
# Internal crates - common/foundation
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
yaak-database = { path = "crates/common/yaak-database" }
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
yaak = { path = "crates/yaak" }
|
||||||
yaak-commands = { path = "crates/yaak-commands" }
|
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
yaak-http = { path = "crates/yaak-http" }
|
||||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
yaak-models = { path = "crates/yaak-models" }
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
yaak-sse = { path = "crates/yaak-sse" }
|
||||||
@@ -98,13 +84,8 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
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" }
|
|
||||||
|
|||||||
@@ -44,25 +44,6 @@ After bootstrapping, start the app in development mode:
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run the App in a Browser
|
|
||||||
|
|
||||||
The client can also run as a plain web page, with no Tauri and no local process
|
|
||||||
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
|
||||||
```
|
|
||||||
|
|
||||||
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
|
||||||
commands from an IndexedDB database the page owns instead of from the Rust
|
|
||||||
engine. Data persists across reloads and is shared between tabs on the same
|
|
||||||
origin. Sending HTTP is not available yet — the Send button reports that and
|
|
||||||
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
|
||||||
lists which commands the browser host implements and which it declines.
|
|
||||||
|
|
||||||
Desktop builds are unaffected: without the flag the platform package installs
|
|
||||||
the Tauri host exactly as before.
|
|
||||||
|
|
||||||
## SQLite Migrations
|
## SQLite Migrations
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
New migrations can be created from the `src-tauri/` directory:
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
|
||||||
# image serving both from one origin.
|
|
||||||
#
|
|
||||||
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
|
||||||
#
|
|
||||||
# See crates-server/yaak-web/README.md for the knobs.
|
|
||||||
|
|
||||||
FROM node:22-slim AS web
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY . .
|
|
||||||
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
|
||||||
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
|
||||||
RUN git init -q && git add -A \
|
|
||||||
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
|
||||||
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
|
||||||
# only to build a bundle for a deployment whose server lives somewhere else.
|
|
||||||
ARG VITE_YAAK_WEB_URL=""
|
|
||||||
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
|
||||||
ENV YAAK_TARGET=web
|
|
||||||
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
|
||||||
# backend, which this image has no reason to carry.
|
|
||||||
ENV SKIP_WASM_BUILD=1
|
|
||||||
RUN npm ci
|
|
||||||
RUN node_modules/.bin/vp -C apps/yaak-client build
|
|
||||||
|
|
||||||
FROM rust:1-bookworm AS server
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY . .
|
|
||||||
RUN cargo build --release -p yaak-web
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
|
||||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
|
||||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
|
||||||
EXPOSE 8080
|
|
||||||
USER nobody
|
|
||||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
|
||||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
|
||||||
CMD ["yaak-web", "--serve", "/srv"]
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModelById } from "@yaakapp-internal/models";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
|
|
||||||
export function looksLikeCurl(text: string) {
|
|
||||||
return text.trim().startsWith("curl ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const importCurl = createFastMutation<
|
|
||||||
void,
|
|
||||||
string,
|
|
||||||
{ overwriteRequestId?: string; command: string }
|
|
||||||
>({
|
|
||||||
mutationKey: ["import_curl"],
|
|
||||||
mutationFn: async ({ overwriteRequestId, command }) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
|
|
||||||
command,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
let verb: string;
|
|
||||||
if (overwriteRequestId == null) {
|
|
||||||
verb = "Created";
|
|
||||||
await createRequestAndNavigate(importedRequest);
|
|
||||||
} else {
|
|
||||||
verb = "Updated";
|
|
||||||
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
|
||||||
...importedRequest,
|
|
||||||
id: r.id,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
workspaceId: r.workspaceId,
|
|
||||||
folderId: r.folderId,
|
|
||||||
name: r.name,
|
|
||||||
sortPriority: r.sortPriority,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
color: "success",
|
|
||||||
message: `${verb} request from Curl`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,44 +1,26 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import type { SettingsTab } from "../components/Settings/Settings";
|
||||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { rpc } from "../lib/rpc";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||||
|
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||||
|
|
||||||
|
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||||
mutationKey: ["open_settings"],
|
mutationKey: ["open_settings"],
|
||||||
mutationFn: async (tab) => {
|
mutationFn: async (tab) => {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
if (workspaceId == null) return;
|
||||||
|
|
||||||
// Settings is its own window where the host has windows to give. Where it
|
|
||||||
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
|
||||||
// doesn't take you away from the request you were working on.
|
|
||||||
if (!platform.capabilities.multiWindow) {
|
|
||||||
// Imported here so Settings stays out of the startup bundle, the way the
|
|
||||||
// route that renders it on desktop already keeps it
|
|
||||||
const { default: Settings } = await import("../components/Settings/Settings");
|
|
||||||
showDialog({
|
|
||||||
id: "settings",
|
|
||||||
size: "md",
|
|
||||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
|
||||||
noPadding: true,
|
|
||||||
noScroll: true,
|
|
||||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
|
||||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId/settings",
|
to: "/workspaces/$workspaceId/settings",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
await rpc("cmd_new_child_window", {
|
await invokeCmd("cmd_new_child_window", {
|
||||||
url: location.href,
|
url: location.href,
|
||||||
label: "settings",
|
label: "settings",
|
||||||
title: "Yaak Settings",
|
title: "Yaak Settings",
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { rpc } from "../lib/rpc";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
|
|
||||||
export const switchWorkspace = createFastMutation<
|
export const switchWorkspace = createFastMutation<
|
||||||
void,
|
void,
|
||||||
@@ -25,15 +24,13 @@ export const switchWorkspace = createFastMutation<
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A host without windows opens the workspace here instead. Refusing would
|
if (inNewWindow) {
|
||||||
// strand the user on the workspace they were trying to leave.
|
|
||||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId",
|
to: "/workspaces/$workspaceId",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search,
|
search,
|
||||||
});
|
});
|
||||||
await rpc<void>("cmd_new_main_window", { url: location.href });
|
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { gitClone } from "@yaakapp-internal/git";
|
import { gitClone } from "@yaakapp-internal/git";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
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";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { promptCredentials } from "./git/credentials";
|
import { promptCredentials } from "./git/credentials";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
@@ -38,7 +37,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
const handleSelectDirectory = async () => {
|
||||||
const dir = await platform.dialog.open({
|
const dir = await open({
|
||||||
title: "Select Directory",
|
title: "Select Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -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,129 +0,0 @@
|
|||||||
import { checkLicense } 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";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
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 checkLicense();
|
|
||||||
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 platform.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,26 +0,0 @@
|
|||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
|
||||||
import { ContextMenu } from "./core/Dropdown";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
|
|
||||||
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
|
||||||
export function ContextMenus() {
|
|
||||||
const menus = useAtomValue(contextMenusAtom);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
|
||||||
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
|
||||||
<ContextMenu
|
|
||||||
items={items}
|
|
||||||
triggerPosition={triggerPosition}
|
|
||||||
triggerRect={triggerRect}
|
|
||||||
// The trigger isn't a React component here, so it arrives as an element and gets
|
|
||||||
// wrapped to look like the ref the menu expects
|
|
||||||
triggerRef={{ current: triggerEl ?? null }}
|
|
||||||
onClose={() => hideContextMenu(id)}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} />,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
import { rpc } from "../lib/rpc";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
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";
|
||||||
@@ -39,7 +39,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
|
|
||||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
// Do getWorkspaceMeta instead of naively creating one because it might have
|
||||||
// been created already when the store refreshes the workspace meta after
|
// been created already when the store refreshes the workspace meta after
|
||||||
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
|
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
await updateModel({
|
await updateModel({
|
||||||
|
|||||||
@@ -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,19 +62,12 @@ 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.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={
|
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||||
subEnvironments.length === 0
|
|
||||||
? (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
editEnvironment(null);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Workspace } from "@yaakapp-internal/models";
|
import type { Workspace } from "@yaakapp-internal/models";
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||||
@@ -6,13 +7,11 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import slugify from "slugify";
|
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 { rpc } from "../lib/rpc";
|
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";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
@@ -65,7 +64,7 @@ function ExportDataDialogContent({
|
|||||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||||
const exportPath = await platform.dialog.save({
|
const exportPath = await save({
|
||||||
title: "Export Data",
|
title: "Export Data",
|
||||||
defaultPath: `yaak.${slug}.json`,
|
defaultPath: `yaak.${slug}.json`,
|
||||||
});
|
});
|
||||||
@@ -73,7 +72,7 @@ function ExportDataDialogContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rpc("cmd_export_data", {
|
await invokeCmd("cmd_export_data", {
|
||||||
workspaceIds: ids,
|
workspaceIds: ids,
|
||||||
exportPath,
|
exportPath,
|
||||||
includePrivateEnvironments: includePrivateEnvironments,
|
includePrivateEnvironments: includePrivateEnvironments,
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
|
||||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
import { hideToastById, showToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Input } from "./core/Input";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
feature: FeedbackFeature;
|
|
||||||
onDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedbackToast({ feature, onDone }: Props) {
|
|
||||||
const [text, setText] = useState<string>("");
|
|
||||||
const [sent, setSent] = useState(false);
|
|
||||||
const sentRef = useRef(false);
|
|
||||||
|
|
||||||
const handleDismiss = () => {
|
|
||||||
onDone();
|
|
||||||
hideToastById(`feature-feedback-${feature}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
const trimmedText = text.trim();
|
|
||||||
if (sentRef.current || trimmedText.length === 0) return;
|
|
||||||
|
|
||||||
sentRef.current = true;
|
|
||||||
setSent(true);
|
|
||||||
onDone();
|
|
||||||
|
|
||||||
// Fire-and-forget; failures are intentionally ignored
|
|
||||||
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
|
||||||
showToast({
|
|
||||||
id: `feature-feedback-${feature}`,
|
|
||||||
timeout: 3000,
|
|
||||||
color: "success",
|
|
||||||
message: "Thanks for the feedback!",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={2}>
|
|
||||||
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
|
||||||
<div className="h-20">
|
|
||||||
<Input
|
|
||||||
size="xs"
|
|
||||||
// The editor forces its mono font on the scroller, so the override
|
|
||||||
// has to target it directly
|
|
||||||
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
|
||||||
label="Feedback"
|
|
||||||
hideLabel
|
|
||||||
stateKey={null}
|
|
||||||
multiLine
|
|
||||||
fullHeight
|
|
||||||
placeholder="Your thoughts..."
|
|
||||||
onChange={setText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<HStack space={1.5} justifyContent="end">
|
|
||||||
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
disabled={sent || text.trim().length === 0}
|
|
||||||
onClick={handleSend}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
||||||
<code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
|
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
||||||
{request.method} {request.url}
|
{request.method} {request.url}
|
||||||
</code>
|
</code>
|
||||||
{latestResponse ? (
|
{latestResponse ? (
|
||||||
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"cursor-default select-none",
|
"cursor-default select-none",
|
||||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||||
"font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
|
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
|
|||||||
import { HeadersEditor } from "./HeadersEditor";
|
import { HeadersEditor } from "./HeadersEditor";
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
@@ -30,7 +29,6 @@ interface Props {
|
|||||||
|
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
const TAB_SETTINGS = "settings";
|
|
||||||
const TAB_VARIABLES = "variables";
|
const TAB_VARIABLES = "variables";
|
||||||
const TAB_GENERAL = "general";
|
const TAB_GENERAL = "general";
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ export type FolderSettingsTab =
|
|||||||
| typeof TAB_AUTH
|
| typeof TAB_AUTH
|
||||||
| typeof TAB_HEADERS
|
| typeof TAB_HEADERS
|
||||||
| typeof TAB_GENERAL
|
| typeof TAB_GENERAL
|
||||||
| typeof TAB_SETTINGS
|
|
||||||
| typeof TAB_VARIABLES;
|
| typeof TAB_VARIABLES;
|
||||||
|
|
||||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||||
@@ -54,7 +51,6 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
||||||
);
|
);
|
||||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||||
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
if (folder == null) return [];
|
if (folder == null) return [];
|
||||||
@@ -64,11 +60,6 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
value: TAB_GENERAL,
|
value: TAB_GENERAL,
|
||||||
label: "General",
|
label: "General",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: TAB_SETTINGS,
|
|
||||||
label: "Settings",
|
|
||||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
|
||||||
},
|
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
{
|
{
|
||||||
@@ -77,19 +68,19 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
}, [authTab, folder, headersTab, numVars]);
|
||||||
|
|
||||||
if (folder == null) return null;
|
if (folder == null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||||
<Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
|
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
||||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
||||||
{breadcrumbs.map((item, index) => (
|
{breadcrumbs.map((item, index) => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
|
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -97,7 +88,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{breadcrumbs.length > 0 && (
|
{breadcrumbs.length > 0 && (
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
|
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="whitespace-nowrap" title={folder.name}>
|
<span className="whitespace-nowrap" title={folder.name}>
|
||||||
{folder.name}
|
{folder.name}
|
||||||
@@ -149,7 +140,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
||||||
{folder.id}
|
{folder.id}
|
||||||
<CopyIconButton
|
<CopyIconButton
|
||||||
className="opacity-70 text-primary!"
|
className="opacity-70 !text-primary"
|
||||||
size="2xs"
|
size="2xs"
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
title="Copy folder ID"
|
title="Copy folder ID"
|
||||||
@@ -168,9 +159,6 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
stateKey={`headers.${folder.id}`}
|
stateKey={`headers.${folder.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
|
||||||
<ModelSettingsEditor model={folder} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||||
{folderEnvironment == null ? (
|
{folderEnvironment == null ? (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { linter } from "@codemirror/lint";
|
import { linter } from "@codemirror/lint";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
jsonCompletion,
|
jsonCompletion,
|
||||||
@@ -10,20 +11,12 @@ import {
|
|||||||
stateExtensions,
|
stateExtensions,
|
||||||
updateSchema,
|
updateSchema,
|
||||||
} from "codemirror-json-schema";
|
} from "codemirror-json-schema";
|
||||||
import type { JSONSchema7 } from "json-schema";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { showAlert } from "../lib/alert";
|
import { showAlert } from "../lib/alert";
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
|
||||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { queryClient } from "../lib/queryClient";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import type { EditorProps } from "./core/Editor/Editor";
|
import type { EditorProps } from "./core/Editor/Editor";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||||
@@ -36,11 +29,6 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
|||||||
protoFiles: string[];
|
protoFiles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MethodSchema =
|
|
||||||
| { type: "none" }
|
|
||||||
| { type: "schema"; schema: JsonSchema }
|
|
||||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
|
||||||
|
|
||||||
export function GrpcEditor({
|
export function GrpcEditor({
|
||||||
services,
|
services,
|
||||||
reflectionError,
|
reflectionError,
|
||||||
@@ -54,16 +42,21 @@ export function GrpcEditor({
|
|||||||
setEditorView(h);
|
setEditorView(h);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find the schema for the selected service and method
|
// Find the schema for the selected service and method and update the editor
|
||||||
const methodSchema = useMemo<MethodSchema>(() => {
|
useEffect(() => {
|
||||||
if (services === null || request.service === null || request.method === null) {
|
if (
|
||||||
return { type: "none" };
|
editorView == null ||
|
||||||
|
services === null ||
|
||||||
|
request.service === null ||
|
||||||
|
request.method === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = services.find((s) => s.name === request.service);
|
const s = services.find((s) => s.name === request.service);
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return {
|
console.log("Failed to find service", { service: request.service, services });
|
||||||
type: "error",
|
showAlert({
|
||||||
id: "grpc-find-service-error",
|
id: "grpc-find-service-error",
|
||||||
title: "Couldn't Find Service",
|
title: "Couldn't Find Service",
|
||||||
body: (
|
body: (
|
||||||
@@ -71,14 +64,14 @@ export function GrpcEditor({
|
|||||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
log: ["Failed to find service", { service: request.service, services }],
|
});
|
||||||
};
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||||
if (schema == null) {
|
if (request.method != null && schema == null) {
|
||||||
return {
|
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||||
type: "error",
|
showAlert({
|
||||||
id: "grpc-find-schema-error",
|
id: "grpc-find-schema-error",
|
||||||
title: "Couldn't Find Method",
|
title: "Couldn't Find Method",
|
||||||
body: (
|
body: (
|
||||||
@@ -87,15 +80,18 @@ export function GrpcEditor({
|
|||||||
<InlineCode>{request.service}</InlineCode> in schema
|
<InlineCode>{request.service}</InlineCode> in schema
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
});
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
updateSchema(editorView, JSON.parse(schema));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
showAlert({
|
||||||
type: "error",
|
|
||||||
id: "grpc-parse-schema-error",
|
id: "grpc-parse-schema-error",
|
||||||
title: "Failed to Parse Schema",
|
title: "Failed to Parse Schema",
|
||||||
body: (
|
body: (
|
||||||
@@ -107,22 +103,9 @@ export function GrpcEditor({
|
|||||||
<FormattedError>{String(err)}</FormattedError>
|
<FormattedError>{String(err)}</FormattedError>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
log: ["Failed to parse schema", err],
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [services, request.method, request.service]);
|
}, [editorView, services, request.method, request.service]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (methodSchema.type !== "error") return;
|
|
||||||
console.log(...methodSchema.log);
|
|
||||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
|
||||||
}, [methodSchema]);
|
|
||||||
|
|
||||||
// Update the editor whenever the schema changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (editorView == null || methodSchema.type !== "schema") return;
|
|
||||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
|
||||||
}, [editorView, methodSchema]);
|
|
||||||
|
|
||||||
const extraExtensions = useMemo(
|
const extraExtensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -141,145 +124,45 @@ export function GrpcEditor({
|
|||||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||||
|
|
||||||
const handleGenerateExample = useCallback(async () => {
|
|
||||||
if (methodSchema.type !== "schema") return;
|
|
||||||
|
|
||||||
if (request.message.trim() !== "") {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "grpc-generate-example",
|
|
||||||
title: "Generate Example",
|
|
||||||
description: "The current message will be replaced with an example.",
|
|
||||||
confirmText: "Generate",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
|
||||||
await patchModel(request, { message });
|
|
||||||
|
|
||||||
// Force the editor to pick up the new message
|
|
||||||
wasUpdatedExternally(request.id);
|
|
||||||
}, [methodSchema, request]);
|
|
||||||
|
|
||||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
|
||||||
// reaches it without threading a refetch down from the connection layout.
|
|
||||||
const handleReloadSchema = useCallback(
|
|
||||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
|
||||||
[request.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowReflectionError = useCallback(() => {
|
|
||||||
showDialog({
|
|
||||||
id: "grpc-reflection-error",
|
|
||||||
title: "Reflection Failed",
|
|
||||||
size: "sm",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<>
|
|
||||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
|
||||||
<div className="w-full my-4">
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
await handleReloadSchema();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [handleReloadSchema, reflectionError]);
|
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
<div key="reflection" className={classNames(services == null && "!opacity-100")}>
|
||||||
// with everything schema-related behind it.
|
<Button
|
||||||
<div key="schema" className="opacity-100!">
|
size="xs"
|
||||||
<Dropdown
|
color={
|
||||||
items={[
|
reflectionLoading
|
||||||
{
|
? "secondary"
|
||||||
// Hidden for servers without reflection, which isn't an error
|
|
||||||
hidden: !reflectionError,
|
|
||||||
type: "content",
|
|
||||||
label: (
|
|
||||||
<Banner color="danger">
|
|
||||||
<p className="mb-1">Reflection failed</p>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="danger"
|
|
||||||
variant="border"
|
|
||||||
onClick={handleShowReflectionError}
|
|
||||||
>
|
|
||||||
View Error
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Generate Example Message",
|
|
||||||
leftSlot: <Icon icon="magic_wand" />,
|
|
||||||
disabled: methodSchema.type !== "schema",
|
|
||||||
onSelect: handleGenerateExample,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Reload Schema",
|
|
||||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: handleReloadSchema,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => {
|
|
||||||
showDialog({
|
|
||||||
title: "Configure Schema",
|
|
||||||
size: "md",
|
|
||||||
id: "grpc-configure-schema",
|
|
||||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title="Schema"
|
|
||||||
forDropdown
|
|
||||||
isLoading={reflectionLoading}
|
|
||||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
|
||||||
>
|
|
||||||
{reflectionLoading
|
|
||||||
? "Inspecting Schema"
|
|
||||||
: reflectionUnavailable
|
: reflectionUnavailable
|
||||||
? "Select Proto Files"
|
? "info"
|
||||||
: reflectionError
|
: reflectionError
|
||||||
? "Server Error"
|
? "danger"
|
||||||
: protoFiles.length > 0
|
: "secondary"
|
||||||
? pluralizeCount("File", protoFiles.length)
|
}
|
||||||
: services != null
|
isLoading={reflectionLoading}
|
||||||
? "Schema Detected"
|
onClick={() => {
|
||||||
: "Select Schema"}
|
showDialog({
|
||||||
</Button>
|
title: "Configure Schema",
|
||||||
</Dropdown>
|
size: "md",
|
||||||
|
id: "reflection-failed",
|
||||||
|
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reflectionLoading
|
||||||
|
? "Inspecting Schema"
|
||||||
|
: reflectionUnavailable
|
||||||
|
? "Select Proto Files"
|
||||||
|
: reflectionError
|
||||||
|
? "Server Error"
|
||||||
|
: protoFiles.length > 0
|
||||||
|
? pluralizeCount("File", protoFiles.length)
|
||||||
|
: services != null && protoFiles.length === 0
|
||||||
|
? "Schema Detected"
|
||||||
|
: "Select Schema"}
|
||||||
|
</Button>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||||
handleGenerateExample,
|
|
||||||
handleReloadSchema,
|
|
||||||
handleShowReflectionError,
|
|
||||||
methodSchema.type,
|
|
||||||
protoFiles.length,
|
|
||||||
reflectionError,
|
|
||||||
reflectionLoading,
|
|
||||||
reflectionUnavailable,
|
|
||||||
services,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||||
@@ -7,7 +8,6 @@ import { pluralizeCount } from "../lib/pluralize";
|
|||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
@@ -45,7 +45,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="border"
|
variant="border"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await platform.dialog.open({
|
const selected = await open({
|
||||||
title: "Select Proto Files",
|
title: "Select Proto Files",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||||
@@ -63,7 +63,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
variant="border"
|
variant="border"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await platform.dialog.open({
|
const selected = await open({
|
||||||
title: "Select Proto Directory",
|
title: "Select Proto Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
||||||
type GrpcRequest,
|
|
||||||
type HttpRequestHeader,
|
|
||||||
patchModel,
|
|
||||||
patchModelDebounced,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -25,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 {
|
||||||
@@ -53,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({
|
||||||
@@ -73,20 +66,17 @@ 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);
|
||||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||||
|
|
||||||
const handleChangeUrl = useCallback(
|
const handleChangeUrl = useCallback(
|
||||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
(url: string) => patchModel(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeMessage = useCallback(
|
const handleChangeMessage = useCallback(
|
||||||
// Not debounced: handleSend reads message from the store, so a pending
|
|
||||||
// debounced patch would send stale text
|
|
||||||
(message: string) => patchModel(activeRequest, { message }),
|
(message: string) => patchModel(activeRequest, { message }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
@@ -138,27 +128,22 @@ 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(
|
||||||
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
const handleDescriptionChange = useCallback(
|
||||||
(description: string) => patchModelDebounced(activeRequest, { description }),
|
(description: string) => patchModel(activeRequest, { description }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,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
|
||||||
@@ -194,6 +179,13 @@ export function GrpcRequestPane({
|
|||||||
type: "default",
|
type: "default",
|
||||||
shortLabel: o.label,
|
shortLabel: o.label,
|
||||||
}))}
|
}))}
|
||||||
|
itemsAfter={[
|
||||||
|
{
|
||||||
|
label: "Refresh",
|
||||||
|
type: "default",
|
||||||
|
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -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,10 +285,10 @@ 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) => patchModelDebounced(activeRequest, { name })}
|
onChange={(name) => patchModel(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||||
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import { acceptLanguages } from "../lib/data/acceptLanguages";
|
|
||||||
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
|
||||||
import { charsets } from "../lib/data/charsets";
|
import { charsets } from "../lib/data/charsets";
|
||||||
import { connections } from "../lib/data/connections";
|
import { connections } from "../lib/data/connections";
|
||||||
import { encodings } from "../lib/data/encodings";
|
import { encodings } from "../lib/data/encodings";
|
||||||
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
|
||||||
import { headerNames } from "../lib/data/headerNames";
|
import { headerNames } from "../lib/data/headerNames";
|
||||||
import { mimeTypes } from "../lib/data/mimetypes";
|
import { mimeTypes } from "../lib/data/mimetypes";
|
||||||
import { userAgents } from "../lib/data/userAgents";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import type { InputProps } from "./core/Input";
|
import type { InputProps } from "./core/Input";
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||||
import { PairEditorRow } from "./core/PairEditor";
|
import { PairEditorRow } from "./core/PairEditor";
|
||||||
@@ -108,21 +105,12 @@ export function HeadersEditor({
|
|||||||
|
|
||||||
const MIN_MATCH = 3;
|
const MIN_MATCH = 3;
|
||||||
|
|
||||||
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
const headerOptionsMap: Record<string, string[]> = {
|
||||||
"content-type": mimeTypes,
|
"content-type": mimeTypes,
|
||||||
accept: ["*/*", ...mimeTypes],
|
accept: ["*/*", ...mimeTypes],
|
||||||
"accept-encoding": encodings,
|
"accept-encoding": encodings,
|
||||||
"content-encoding": encodings,
|
|
||||||
connection: connections,
|
connection: connections,
|
||||||
"accept-charset": charsets,
|
"accept-charset": charsets,
|
||||||
"accept-language": acceptLanguages,
|
|
||||||
"cache-control": cacheControlDirectives,
|
|
||||||
"user-agent": userAgents,
|
|
||||||
pragma: ["no-cache"],
|
|
||||||
te: ["trailers", "compress", "deflate", "gzip"],
|
|
||||||
dnt: ["1", "0"],
|
|
||||||
"upgrade-insecure-requests": ["1"],
|
|
||||||
"x-requested-with": ["XMLHttpRequest"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const valueType = (pair: Pair): InputProps["type"] => {
|
const valueType = (pair: Pair): InputProps["type"] => {
|
||||||
@@ -144,22 +132,12 @@ const valueType = (pair: Pair): InputProps["type"] => {
|
|||||||
|
|
||||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||||
const name = headerName.toLowerCase().trim();
|
const name = headerName.toLowerCase().trim();
|
||||||
const options: GenericCompletion[] =
|
const options: GenericCompletionOption[] =
|
||||||
headerOptionsMap[name]?.map((o) =>
|
headerOptionsMap[name]?.map((o) => ({
|
||||||
typeof o === "string"
|
label: o,
|
||||||
? {
|
type: "constant",
|
||||||
label: o,
|
boost: 1, // Put above other completions
|
||||||
type: "constant",
|
})) ?? [];
|
||||||
boost: 1, // Put above other completions
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
// Show the short label but insert the full value (e.g. User-Agent)
|
|
||||||
label: o.label,
|
|
||||||
apply: o.value,
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
) ?? [];
|
|
||||||
return { minMatch: MIN_MATCH, options };
|
return { minMatch: MIN_MATCH, options };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
import { patchModel } from "@yaakapp-internal/models";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
|
import { useImportCurl } from "../hooks/useImportCurl";
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
|
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 { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
|
||||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
BODY_TYPE_FORM_MULTIPART,
|
||||||
@@ -37,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
|
|||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
|
import type { Pair } from "./core/PairEditor";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
@@ -51,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";
|
||||||
|
|
||||||
@@ -70,16 +69,18 @@ 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";
|
||||||
|
|
||||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||||
// actually changes. The active request's own URL is included, but exact matches
|
const activeRequestId = get(activeRequestIdAtom);
|
||||||
// are filtered out at completion time by genericCompletion.
|
const requests = get(allRequestsAtom);
|
||||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
return requests
|
||||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
.filter((r) => r.id !== activeRequestId)
|
||||||
);
|
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||||
|
|
||||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -91,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(
|
||||||
@@ -127,33 +127,22 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||||
// value detaches from the placeholder.
|
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
(m) => m[1] ?? "",
|
||||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
);
|
||||||
// patch its parameter list back over newer edits.
|
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||||
const handleRenamePathPlaceholder = useCallback(
|
const items: Pair[] = [...nonEmptyParameters];
|
||||||
(oldName: string, newName: string) => {
|
for (const name of placeholderNames) {
|
||||||
const request = getModel("http_request", activeRequestId);
|
const item = items.find((p) => p.name === name);
|
||||||
if (request == null) return false;
|
if (item) {
|
||||||
|
item.readOnlyName = true;
|
||||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
} else {
|
||||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||||
void patchModel(request, patch);
|
}
|
||||||
return true;
|
}
|
||||||
},
|
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||||
[activeRequestId],
|
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||||
);
|
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
|
||||||
() =>
|
|
||||||
derivePathPlaceholderPairs(
|
|
||||||
activeRequest.url,
|
|
||||||
activeRequest.urlParameters,
|
|
||||||
handleRenamePathPlaceholder,
|
|
||||||
),
|
|
||||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
|
||||||
);
|
|
||||||
|
|
||||||
let numParams = 0;
|
let numParams = 0;
|
||||||
if (
|
if (
|
||||||
@@ -204,14 +193,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const patch: Partial<HttpRequest> = {
|
const patch: Partial<HttpRequest> = { bodyType };
|
||||||
bodyType,
|
|
||||||
body: convertRequestBody({
|
|
||||||
body: activeRequest.body,
|
|
||||||
fromBodyType: activeRequest.bodyType,
|
|
||||||
toBodyType: bodyType,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let newContentType: string | null | undefined;
|
let newContentType: string | null | undefined;
|
||||||
if (bodyType === BODY_TYPE_NONE) {
|
if (bodyType === BODY_TYPE_NONE) {
|
||||||
newContentType = null;
|
newContentType = null;
|
||||||
@@ -252,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",
|
||||||
@@ -269,7 +246,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
handleContentTypeChange,
|
handleContentTypeChange,
|
||||||
headersTab,
|
headersTab,
|
||||||
numParams,
|
numParams,
|
||||||
numSettingsOverrides,
|
|
||||||
urlParameterPairs.length,
|
urlParameterPairs.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -278,28 +254,38 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||||
|
const { mutate: importCurl } = useImportCurl();
|
||||||
|
|
||||||
const handleBodyChange = useCallback(
|
const handleBodyChange = useCallback(
|
||||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
const handleBodyTextChange = useCallback(
|
||||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => getUrlCompletionConfig(autocompleteUrls),
|
() => ({
|
||||||
|
minMatch: 3,
|
||||||
|
options:
|
||||||
|
autocompleteUrls.length > 0
|
||||||
|
? autocompleteUrls
|
||||||
|
: [
|
||||||
|
{ label: "http://", type: "constant" },
|
||||||
|
{ label: "https://", type: "constant" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
async (e: ClipboardEvent, text: string) => {
|
async (e: ClipboardEvent, text: string) => {
|
||||||
if (looksLikeCurl(text)) {
|
if (text.startsWith("curl ")) {
|
||||||
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
|
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
||||||
} else {
|
} else {
|
||||||
const patch = prepareImportQuerystring(text);
|
const patch = prepareImportQuerystring(text);
|
||||||
if (patch != null) {
|
if (patch != null) {
|
||||||
@@ -321,7 +307,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
||||||
);
|
);
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
() => sendRequest(activeRequest.id ?? null),
|
() => sendRequest(activeRequest.id ?? null),
|
||||||
@@ -329,7 +315,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
(url: string) => patchModel(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,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}
|
||||||
@@ -375,7 +361,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -383,12 +369,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModelDebounced(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 ? (
|
||||||
@@ -435,7 +418,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
requestId={activeRequest.id}
|
requestId={activeRequest.id}
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
body={activeRequest.body}
|
body={activeRequest.body}
|
||||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
onChange={(body) => patchModel(activeRequest, { body })}
|
||||||
onChangeContentType={handleContentTypeChange}
|
onChangeContentType={handleContentTypeChange}
|
||||||
/>
|
/>
|
||||||
) : typeof activeRequest.bodyType === "string" ? (
|
) : typeof activeRequest.bodyType === "string" ? (
|
||||||
@@ -462,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,13 +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 { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
|
||||||
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";
|
||||||
@@ -81,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[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -98,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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -156,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,
|
||||||
@@ -194,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",
|
||||||
)}
|
)}
|
||||||
@@ -207,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>
|
||||||
@@ -221,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">
|
||||||
@@ -250,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" />}
|
||||||
>
|
>
|
||||||
@@ -263,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}
|
||||||
@@ -276,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>
|
||||||
)}
|
)}
|
||||||
@@ -410,13 +383,14 @@ function EnsureCompleteResponse({
|
|||||||
Component,
|
Component,
|
||||||
}: {
|
}: {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
Component: ComponentType<{ bodyUrl: string }>;
|
Component: ComponentType<{ bodyPath: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Wait until the response has been fully-downloaded before asking for it
|
if (response.bodyPath === null) {
|
||||||
const complete = response.state === "closed";
|
return <div>Empty response body</div>;
|
||||||
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
}
|
||||||
|
|
||||||
if (!complete || bodyUrl.isPending) {
|
// Wait until the response has been fully-downloaded
|
||||||
|
if (response.state !== "closed") {
|
||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<LoadingIcon />
|
<LoadingIcon />
|
||||||
@@ -424,15 +398,7 @@ function EnsureCompleteResponse({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bodyUrl.error) {
|
return <Component bodyPath={response.bodyPath} />;
|
||||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bodyUrl.data == null) {
|
|
||||||
return <div>Empty response body</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component bodyUrl={bodyUrl.data} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -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,112 +0,0 @@
|
|||||||
import { platform, useCapability } from "@yaakapp-internal/platform";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
|
||||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offers to create a request from a Curl command on the clipboard. A host that can read
|
|
||||||
* the clipboard on its own offers it whenever the window is focused; one that would have
|
|
||||||
* to prompt for permission first waits for the user to paste instead.
|
|
||||||
*/
|
|
||||||
export function ImportCurl() {
|
|
||||||
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportCurlButton() {
|
|
||||||
const focused = useWindowFocus();
|
|
||||||
const [clipboardText, setClipboardText] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
|
||||||
useEffect(() => {
|
|
||||||
void platform.clipboard.readText().then(setClipboardText);
|
|
||||||
}, [focused]);
|
|
||||||
|
|
||||||
if (!looksLikeCurl(clipboardText)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color="success"
|
|
||||||
className="rounded-full"
|
|
||||||
rightSlot={<Icon icon="import" size="sm" />}
|
|
||||||
isLoading={isLoading}
|
|
||||||
title="Import Curl command from clipboard"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await importCurl.mutateAsync({ command: clipboardText });
|
|
||||||
setClipboardText(""); // Hide the button until the clipboard changes
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Failed to import curl", e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Import Curl
|
|
||||||
</Button>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportCurlOnPaste() {
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
|
||||||
const command = e.clipboardData?.getData("text/plain") ?? "";
|
|
||||||
if (!looksLikeCurl(command)) return;
|
|
||||||
|
|
||||||
// Editable targets keep the text as text. The URL editor imports it itself.
|
|
||||||
if (isEditable(e.target)) return;
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
id: "curl-paste",
|
|
||||||
color: "success",
|
|
||||||
message: (
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold">Curl command detected</h2>
|
|
||||||
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
action: ({ hide }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="success"
|
|
||||||
className="mr-auto min-w-20"
|
|
||||||
rightSlot={<Icon icon="import" size="sm" />}
|
|
||||||
onClick={() => {
|
|
||||||
hide();
|
|
||||||
importCurl.mutate({ command });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Request
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("paste", handlePaste);
|
|
||||||
return () => document.removeEventListener("paste", handlePaste);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditable(target: EventTarget | null) {
|
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
|
||||||
if (target.isContentEditable) return true;
|
|
||||||
if (target.closest(".cm-editor") != null) return true;
|
|
||||||
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import * as m from "motion/react-m";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useImportCurl } from "../hooks/useImportCurl";
|
||||||
|
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
|
export function ImportCurlButton() {
|
||||||
|
const focused = useWindowFocus();
|
||||||
|
const [clipboardText, setClipboardText] = useState("");
|
||||||
|
|
||||||
|
const importCurl = useImportCurl();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||||
|
useEffect(() => {
|
||||||
|
void readText().then(setClipboardText);
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
if (!clipboardText?.trim().startsWith("curl ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color="success"
|
||||||
|
className="rounded-full"
|
||||||
|
rightSlot={<Icon icon="import" size="sm" />}
|
||||||
|
isLoading={isLoading}
|
||||||
|
title="Import Curl command from clipboard"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await importCurl.mutateAsync({ command: clipboardText });
|
||||||
|
await clear(); // Clear the clipboard so the button goes away
|
||||||
|
setClipboardText("");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to import curl", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import Curl
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,740 +1,54 @@
|
|||||||
import {
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
type Folder,
|
import { useState } from "react";
|
||||||
type ImportDestination,
|
import { useLocalStorage } from "react-use";
|
||||||
type ImportPlan,
|
|
||||||
type ImportPlanItem,
|
|
||||||
type ImportSource,
|
|
||||||
type Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { pluralize } from "../lib/pluralize";
|
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { SelectFile } from "./SelectFile";
|
||||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
|
||||||
import { CheckboxTree } from "./core/CheckboxTree";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentWorkspace: Workspace | null;
|
importData: (filePath: string) => Promise<void>;
|
||||||
workspaces: Workspace[];
|
|
||||||
selectedFolder: Folder | null;
|
|
||||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
|
||||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
|
||||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
|
||||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
|
||||||
commit: (plan: ImportPlan) => Promise<void>;
|
|
||||||
cancel: () => void;
|
|
||||||
onError: (err: unknown) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function ImportDataDialog({ importData }: Props) {
|
||||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
|
||||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
|
||||||
*/
|
|
||||||
function isFilePath(value: string): boolean {
|
|
||||||
return (
|
|
||||||
value.startsWith("/") ||
|
|
||||||
value.startsWith("./") ||
|
|
||||||
value.startsWith("../") ||
|
|
||||||
value.startsWith("~/") ||
|
|
||||||
value.startsWith("\\\\") ||
|
|
||||||
/^[a-zA-Z]:[\\/]/.test(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileName(path: string): string {
|
|
||||||
return path.split(/[/\\]/).at(-1) || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
|
||||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
|
||||||
* patching it in with effects after the first paint.
|
|
||||||
*/
|
|
||||||
export function ImportDataDialog(props: Props) {
|
|
||||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
|
||||||
const { currentWorkspace, listSources } = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
|
||||||
load
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setInitialSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setInitialSources([]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [currentWorkspace, listSources]);
|
|
||||||
|
|
||||||
if (initialSources == null) return null;
|
|
||||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
|
||||||
return sources.reduce<ImportSource | null>(
|
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadedImportDataDialog({
|
|
||||||
currentWorkspace,
|
|
||||||
workspaces,
|
|
||||||
selectedFolder,
|
|
||||||
planFile,
|
|
||||||
planUrl,
|
|
||||||
listSources,
|
|
||||||
findSourcesForOrigin,
|
|
||||||
commit,
|
|
||||||
cancel,
|
|
||||||
onError,
|
|
||||||
initialSources,
|
|
||||||
}: Props & { initialSources: ImportSource[] }) {
|
|
||||||
// A workspace with a linked source is probably being re-imported, so start from that source
|
|
||||||
const prefill = latestSource(initialSources);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
||||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
|
||||||
// null means no explicit choice yet, so the default below applies
|
|
||||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
|
||||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
|
||||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
|
||||||
prefill != null ? initialSources : [],
|
|
||||||
);
|
|
||||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
|
||||||
prefill != null ? [prefill] : [],
|
|
||||||
);
|
|
||||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
|
||||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
|
||||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
|
||||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const trimmedSource = source?.trim() ?? "";
|
|
||||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
|
||||||
|
|
||||||
const selectSource = (value: string) => {
|
return (
|
||||||
setSource(value);
|
<VStack space={5} className="pb-4">
|
||||||
// Remount the input so it shows the path of the newly-picked file
|
<VStack space={1}>
|
||||||
setForceUpdateKey((k) => k + 1);
|
<ul className="list-disc pl-5">
|
||||||
};
|
<li>OpenAPI 3.0, 3.1</li>
|
||||||
|
<li>Postman Collection v2, v2.1</li>
|
||||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
<li>Insomnia v4+</li>
|
||||||
useEffect(() => {
|
<li>Swagger 2.0</li>
|
||||||
return platform.window.onDragDrop((event) => {
|
<li>
|
||||||
if (event.type === "over") {
|
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
||||||
const p = event.position;
|
</li>
|
||||||
const r = ref.current?.getBoundingClientRect();
|
</ul>
|
||||||
if (r == null) return;
|
</VStack>
|
||||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
<VStack space={2}>
|
||||||
} else if (event.type === "drop" && isHovering) {
|
<SelectFile
|
||||||
const p = event.paths[0];
|
filePath={filePath ?? null}
|
||||||
if (p) selectSource(p);
|
onChange={({ filePath }) => setFilePath(filePath)}
|
||||||
setIsHovering(false);
|
/>
|
||||||
} else {
|
{filePath && (
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isHovering, setSource]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trimmedSource === "") {
|
|
||||||
setOriginSources([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setOriginSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => setOriginSources([]));
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
|
||||||
|
|
||||||
// The one workspace this file is linked to, if there is exactly one.
|
|
||||||
const linkedWorkspace = useMemo(() => {
|
|
||||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
|
||||||
if (ids.length !== 1) return null;
|
|
||||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
|
||||||
}, [originSources, workspaces]);
|
|
||||||
|
|
||||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
|
||||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
|
||||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
|
||||||
// explicit choice always wins.
|
|
||||||
const destinationKind =
|
|
||||||
destinationChoice ??
|
|
||||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
|
||||||
|
|
||||||
const destinationWorkspaceId =
|
|
||||||
destinationKind === "current"
|
|
||||||
? (currentWorkspace?.id ?? null)
|
|
||||||
: destinationKind === "other"
|
|
||||||
? otherWorkspaceId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (destinationWorkspaceId == null) {
|
|
||||||
setLinkedSources([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
listSources(destinationWorkspaceId)
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setLinkedSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => setLinkedSources([]));
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [destinationWorkspaceId, listSources]);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
|
||||||
if (selected == null) return;
|
|
||||||
selectSource(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
|
||||||
// is also the destination.
|
|
||||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
|
||||||
|
|
||||||
const destination = (): ImportDestination => {
|
|
||||||
if (destinationWorkspaceId == null) {
|
|
||||||
return { type: "new_workspace" };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "existing_workspace",
|
|
||||||
workspaceId: destinationWorkspaceId,
|
|
||||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreview = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const nextPlan =
|
|
||||||
filePath != null
|
|
||||||
? await planFile(filePath, destination())
|
|
||||||
: await planUrl(trimmedSource, destination());
|
|
||||||
setPlan(nextPlan);
|
|
||||||
setItems(nextPlan.items);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommit = async () => {
|
|
||||||
if (plan == null) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await commit({ ...plan, items });
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
|
||||||
|
|
||||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
|
||||||
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
|
|
||||||
// anything also brings back the folders it needs to live in.
|
|
||||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
|
||||||
const targets = new Set(
|
|
||||||
collectItems(node)
|
|
||||||
.filter((i) => togglesWith(node.data, i))
|
|
||||||
.map((i) => i.modelId),
|
|
||||||
);
|
|
||||||
if (checked) {
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
for (const ancestor of ancestorsOf(node.data, byId)) {
|
|
||||||
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
|
||||||
// new folder can't exist, and a removed folder takes its contents with it.
|
|
||||||
const disabledIds = useMemo(() => {
|
|
||||||
const disabled = new Set<string>();
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
for (const item of items) {
|
|
||||||
for (const parent of ancestorsOf(item, byId)) {
|
|
||||||
if (parent.model !== "folder") break;
|
|
||||||
const missing =
|
|
||||||
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
|
|
||||||
// A not-imported row stays checkable: checking it brings its folders back with it
|
|
||||||
if (missing && item.action !== "delete" && item.action !== "not_imported") {
|
|
||||||
disabled.add(item.modelId);
|
|
||||||
}
|
|
||||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
|
||||||
disabled.add(item.modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return disabled;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
if (plan != null) {
|
|
||||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
|
||||||
const footerNote =
|
|
||||||
unchanged.length > 0
|
|
||||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
|
||||||
: "";
|
|
||||||
const changeCount = items.filter((item) => {
|
|
||||||
if (disabledIds.has(item.modelId)) return false;
|
|
||||||
if (item.action === "conflict") return item.resolution === "take_source";
|
|
||||||
if (item.action === "unchanged") return false;
|
|
||||||
return item.selected;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const destinationLabel = (() => {
|
|
||||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
|
||||||
const { workspaceId, folderId } = plan.destination;
|
|
||||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
|
||||||
return folderId != null && folderId === selectedFolder?.id
|
|
||||||
? `${name} / ${selectedFolder.name}`
|
|
||||||
: name;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
|
||||||
// it — so its checkbox only aggregates the subtree.
|
|
||||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
|
||||||
const planned = plan.resources.workspaces[0];
|
|
||||||
const planDestination = plan.destination;
|
|
||||||
const existing =
|
|
||||||
planDestination.type === "existing_workspace"
|
|
||||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
key: existing?.id ?? planned?.id ?? "workspace",
|
|
||||||
data: {
|
|
||||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
|
||||||
model: "workspace",
|
|
||||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
|
||||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
|
||||||
selected: true,
|
|
||||||
changedFields: [],
|
|
||||||
},
|
|
||||||
children: itemTree,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={4} className="pb-4">
|
|
||||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
|
||||||
<PreviewRow label="Detected format" value={plan.importer} />
|
|
||||||
<PreviewRow label="Destination" value={destinationLabel} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
|
||||||
<CheckboxTree
|
|
||||||
node={workspaceRoot}
|
|
||||||
checked={nodeCheckedStatus}
|
|
||||||
onCheck={toggleNode}
|
|
||||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
|
||||||
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
|
|
||||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
|
||||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{plan.warnings.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
|
||||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
|
||||||
{plan.warnings.map((warning) => (
|
|
||||||
<div
|
|
||||||
key={`${warning.title}:${warning.detail}`}
|
|
||||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium">{warning.title}</div>
|
|
||||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HStack space={2} alignItems="center" className="mt-3">
|
|
||||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
|
||||||
<Button
|
<Button
|
||||||
className="ml-auto"
|
color="primary"
|
||||||
color="secondary"
|
disabled={!filePath || isLoading}
|
||||||
variant="border"
|
isLoading={isLoading}
|
||||||
disabled={isLoading}
|
size="sm"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setPlan(null);
|
setIsLoading(true);
|
||||||
setItems([]);
|
try {
|
||||||
|
await importData(filePath);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
{isLoading ? "Importing" : "Import"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
|
||||||
{isLoading
|
|
||||||
? "Importing"
|
|
||||||
: changeCount > 0
|
|
||||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
|
||||||
: "Done"}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastImported =
|
|
||||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
|
||||||
linkedSources.reduce<ImportSource | null>(
|
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack ref={ref} space={4} className="pb-4">
|
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSelectFile}
|
|
||||||
className={classNames(
|
|
||||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
|
||||||
"flex flex-col items-center gap-1 text-center",
|
|
||||||
isHovering
|
|
||||||
? "border-notice bg-surface-highlight"
|
|
||||||
: "border-border hover:border-text-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
|
||||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
|
||||||
<div className="h-6 w-full flex items-center justify-center">
|
|
||||||
{filePath == null ? (
|
|
||||||
<div className="text-text">
|
|
||||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
|
||||||
{fileName(filePath)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-subtlest">
|
|
||||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PlainInput
|
|
||||||
label="Or enter a file path or URL"
|
|
||||||
size="sm"
|
|
||||||
placeholder="https://example.com/openapi.json"
|
|
||||||
defaultValue={source ?? ""}
|
|
||||||
forceUpdateKey={String(forceUpdateKey)}
|
|
||||||
onChange={setSource}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VStack space={2}>
|
|
||||||
<Select
|
|
||||||
name="import-destination-kind"
|
|
||||||
label="Import location"
|
|
||||||
size="sm"
|
|
||||||
value={destinationKind}
|
|
||||||
onChange={setDestinationChoice}
|
|
||||||
options={[
|
|
||||||
{ value: "new", label: "New Workspace" },
|
|
||||||
...(currentWorkspace != null
|
|
||||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
|
||||||
: []),
|
|
||||||
{ value: "other", label: "Other Workspace" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{destinationKind === "other" && (
|
|
||||||
<Select
|
|
||||||
name="import-destination-workspace"
|
|
||||||
label="Workspace"
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
value={otherWorkspaceId ?? ""}
|
|
||||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
|
||||||
filterable
|
|
||||||
options={[
|
|
||||||
{ value: "", label: "Select a workspace" },
|
|
||||||
...workspaces
|
|
||||||
.filter((w) => w.id !== currentWorkspace?.id)
|
|
||||||
.map((w) => ({ value: w.id, label: w.name })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{lastImported != null && destinationWorkspaceId != null ? (
|
|
||||||
<div className="text-xs text-text-subtle">
|
|
||||||
Last imported from {lastImported.originLabel} ·{" "}
|
|
||||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
|
||||||
<div className="text-xs text-text-subtle">
|
|
||||||
This file was last imported into{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline hocus:text-text"
|
|
||||||
onClick={() => {
|
|
||||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
|
||||||
setDestinationChoice("current");
|
|
||||||
} else {
|
|
||||||
setDestinationChoice("other");
|
|
||||||
setOtherWorkspaceId(linkedWorkspace.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{linkedWorkspace.name}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{canTargetSelectedFolder && (
|
|
||||||
<Checkbox
|
|
||||||
checked={targetSelectedFolder}
|
|
||||||
title={`Place root resources in selected folder “${selectedFolder.name}”`}
|
|
||||||
onChange={setTargetSelectedFolder}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
disabled={
|
|
||||||
trimmedSource === "" ||
|
|
||||||
isLoading ||
|
|
||||||
(destinationKind === "other" && otherWorkspaceId == null)
|
|
||||||
}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={handlePreview}
|
|
||||||
>
|
|
||||||
{isLoading ? "Analyzing" : "Preview Import"}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportTreeRow({
|
|
||||||
item,
|
|
||||||
onResolveConflict,
|
|
||||||
}: {
|
|
||||||
item: ImportPlanItem;
|
|
||||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
|
||||||
<Icon
|
|
||||||
color="secondary"
|
|
||||||
icon={
|
|
||||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden className="w-4" />
|
|
||||||
)}
|
|
||||||
<div className="truncate flex-1">{item.name}</div>
|
|
||||||
{item.action === "conflict" ? (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<SegmentedControl
|
|
||||||
name={`conflict-${item.modelId}`}
|
|
||||||
label={`Resolve conflict for ${item.name}`}
|
|
||||||
hideLabel
|
|
||||||
size="2xs"
|
|
||||||
help={actionHelp(item)}
|
|
||||||
value={item.resolution ?? "keep_mine"}
|
|
||||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
|
||||||
options={[
|
|
||||||
{ value: "keep_mine", label: "Keep mine" },
|
|
||||||
{ value: "take_source", label: "Take source" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
actionLabel(item) && (
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
|
||||||
"inline-flex items-center justify-center gap-1.5",
|
|
||||||
item.action === "create" && "text-success",
|
|
||||||
item.action === "update" && "text-info",
|
|
||||||
item.action === "delete" && "text-danger",
|
|
||||||
item.action === "keep_local" && item.selected && "text-warning",
|
|
||||||
item.action === "not_imported" && "text-text-subtlest",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{actionLabel(item)}
|
|
||||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
|
||||||
</InlineCode>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(item: ImportPlanItem): string | null {
|
|
||||||
switch (item.action) {
|
|
||||||
case "create":
|
|
||||||
return "new";
|
|
||||||
case "update":
|
|
||||||
return "updated";
|
|
||||||
case "delete":
|
|
||||||
return "removed";
|
|
||||||
case "keep_local":
|
|
||||||
return "edited";
|
|
||||||
case "not_imported":
|
|
||||||
return "not imported";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionHelp(item: ImportPlanItem): string | null {
|
|
||||||
const help = (text: string) =>
|
|
||||||
item.changedFields.length > 0
|
|
||||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
|
||||||
: text;
|
|
||||||
switch (item.action) {
|
|
||||||
case "create":
|
|
||||||
return "Added since the last import";
|
|
||||||
case "update":
|
|
||||||
return help("Changed since the last import");
|
|
||||||
case "delete":
|
|
||||||
return item.reason === "moved_into_not_imported_folder"
|
|
||||||
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
|
|
||||||
: "Deleted since the last import";
|
|
||||||
case "keep_local":
|
|
||||||
return help("Local edits made since the last import. Importing will revert them if checked");
|
|
||||||
case "conflict":
|
|
||||||
return help("Changed both here and in the file since the last import");
|
|
||||||
case "not_imported":
|
|
||||||
return "In the file, but not imported. Check it to import it";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLabel(field: string): string {
|
|
||||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every plan item above `item`, nearest first. */
|
|
||||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
|
||||||
const ancestors: ImportPlanItem[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let parentId = item.parentId;
|
|
||||||
while (parentId != null && !seen.has(parentId)) {
|
|
||||||
seen.add(parentId);
|
|
||||||
const parent = byId.get(parentId);
|
|
||||||
if (parent == null) break;
|
|
||||||
ancestors.push(parent);
|
|
||||||
parentId = parent.parentId;
|
|
||||||
}
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
|
||||||
const roots: ImportPlanItem[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.parentId != null && byId.has(item.parentId)) {
|
|
||||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
|
||||||
siblings.push(item);
|
|
||||||
childrenOf.set(item.parentId, siblings);
|
|
||||||
} else {
|
|
||||||
roots.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
|
||||||
...list.filter((i) => i.model === "folder"),
|
|
||||||
...list.filter((i) => i.model !== "folder"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
|
||||||
key: item.modelId,
|
|
||||||
data: item,
|
|
||||||
children: seen.has(item.modelId)
|
|
||||||
? []
|
|
||||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
|
||||||
toNode(c, new Set([...seen, item.modelId])),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
|
||||||
return [node.data, ...node.children.flatMap(collectItems)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
|
||||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
|
||||||
*/
|
|
||||||
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
|
||||||
if (item.model === "workspace") return false;
|
|
||||||
if (root.action === "delete") return item.action === "delete";
|
|
||||||
if (item.action === "keep_local") {
|
|
||||||
return root.modelId === item.modelId && item.model !== "folder";
|
|
||||||
}
|
|
||||||
return item.action === "create" || item.action === "update" || item.action === "not_imported";
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeCheckedStatus(
|
|
||||||
node: CheckboxTreeNode<ImportPlanItem>,
|
|
||||||
): boolean | "indeterminate" | "hidden" {
|
|
||||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
|
||||||
if (covered.length === 0) return "hidden";
|
|
||||||
const selected = covered.filter((i) => i.selected).length;
|
|
||||||
if (selected === covered.length) return true;
|
|
||||||
if (selected === 0) return false;
|
|
||||||
return "indeterminate";
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
|
||||||
<span className="text-text-subtle">{label}</span>
|
|
||||||
<span className="text-right font-medium">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
|||||||
const actions = useMemo<EditorProps["actions"]>(
|
const actions = useMemo<EditorProps["actions"]>(
|
||||||
() => [
|
() => [
|
||||||
showBanner && (
|
showBanner && (
|
||||||
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
|
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
|
||||||
<p className="inline-flex items-center gap-1 min-w-0">
|
<p className="inline-flex items-center gap-1 min-w-0">
|
||||||
<span className="truncate">Auto-fix enabled</span>
|
<span className="truncate">Auto-fix enabled</span>
|
||||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||||
</p>
|
</p>
|
||||||
</Banner>
|
</Banner>
|
||||||
),
|
),
|
||||||
<div key="settings" className="opacity-100! shadow!">
|
<div key="settings" className="!opacity-100 !shadow">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
onOpen={handleDropdownOpen}
|
onOpen={handleDropdownOpen}
|
||||||
items={
|
items={
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
|
||||||
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
|
||||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
|
||||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The whole value, exactly as it reads in the document */
|
|
||||||
text: string;
|
|
||||||
sniffed: SniffedValue | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
|
||||||
*
|
|
||||||
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
|
||||||
* explicit click, behind a spinner, rather than during layout.
|
|
||||||
*/
|
|
||||||
export function LargeValueDialog({ text, sniffed }: Props) {
|
|
||||||
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
|
||||||
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
|
||||||
|
|
||||||
// Nothing recognised it, so there is nothing to decode it into
|
|
||||||
if (sniffed == null || viewer == null) {
|
|
||||||
return (
|
|
||||||
<PagedText text={text}>
|
|
||||||
{sniffed != null && (
|
|
||||||
<Banner color="info" className="mb-3">
|
|
||||||
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
|
||||||
response.
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
</PagedText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decoded == null) {
|
|
||||||
return (
|
|
||||||
<HStack className="h-full" alignItems="center" justifyContent="center">
|
|
||||||
<LoadingIcon />
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("error" in decoded) {
|
|
||||||
return (
|
|
||||||
<PagedText text={text}>
|
|
||||||
<Banner color="danger" className="mb-3">
|
|
||||||
Failed to decode this {sniffed.label} value: {decoded.error}
|
|
||||||
</Banner>
|
|
||||||
</PagedText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bytes } = decoded;
|
|
||||||
|
|
||||||
switch (viewer) {
|
|
||||||
case "svg":
|
|
||||||
return <DecodedSvg bytes={bytes} />;
|
|
||||||
case "image":
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-auto flex items-center justify-center">
|
|
||||||
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "audio":
|
|
||||||
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
|
||||||
case "video":
|
|
||||||
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
|
||||||
case "pdf":
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<LoadingIcon />}>
|
|
||||||
<PdfViewer data={bytes} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
case "text":
|
|
||||||
return <DecodedText bytes={bytes} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
|
||||||
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
|
||||||
*/
|
|
||||||
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
|
||||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
|
||||||
return <SvgViewer text={text} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
|
||||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
|
||||||
return <PagedText text={text} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
|
||||||
*
|
|
||||||
* It lives in the dialog's title rather than above the content, which would cost a band of
|
|
||||||
* whitespace the width of the dialog to hold one small button.
|
|
||||||
*/
|
|
||||||
function LargeValueActions({ text, sniffed }: Props) {
|
|
||||||
const items = useMemo(
|
|
||||||
() =>
|
|
||||||
largeValueActions({
|
|
||||||
value: () => text,
|
|
||||||
sniffed,
|
|
||||||
// The tag copies only what it hides; in here, what you see is the whole value
|
|
||||||
copyText: () => text,
|
|
||||||
}),
|
|
||||||
[text, sniffed],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<Button size="xs" variant="border" forDropdown>
|
|
||||||
Actions
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
|
||||||
|
|
||||||
/** Which viewer a media type calls for, or null if none of them can show it. */
|
|
||||||
function viewerFor(mime: string): Viewer | null {
|
|
||||||
if (mime.startsWith("image/svg")) return "svg";
|
|
||||||
if (mime.startsWith("image/")) return "image";
|
|
||||||
if (mime.startsWith("audio/")) return "audio";
|
|
||||||
if (mime.startsWith("video/")) return "video";
|
|
||||||
if (mime === "application/pdf") return "pdf";
|
|
||||||
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Decoded = { bytes: Uint8Array } | { error: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The value's bytes, once they exist.
|
|
||||||
*
|
|
||||||
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
|
||||||
* during render — the dialog paints with a spinner first, instead of opening late.
|
|
||||||
*/
|
|
||||||
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
|
||||||
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sniffed == null) return;
|
|
||||||
|
|
||||||
setDecoded(null);
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
let result: Decoded;
|
|
||||||
try {
|
|
||||||
result = { bytes: decodeValue(text, sniffed) };
|
|
||||||
} catch (err) {
|
|
||||||
result = { error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
if (!cancelled) setDecoded(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [text, sniffed]);
|
|
||||||
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
|
||||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
||||||
return bytes.slice().buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
|
||||||
const PAGE_CHARS = 50_000;
|
|
||||||
|
|
||||||
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
|
||||||
const WRAP_CHARS = 120;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The raw text, in pages of short lines.
|
|
||||||
*
|
|
||||||
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
|
||||||
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
|
||||||
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
|
||||||
*/
|
|
||||||
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
|
||||||
const from = page * PAGE_CHARS;
|
|
||||||
const to = Math.min(from + PAGE_CHARS, text.length);
|
|
||||||
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
|
||||||
<div>{children}</div>
|
|
||||||
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
|
||||||
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
|
||||||
<span>
|
|
||||||
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
|
||||||
</span>
|
|
||||||
{pages > 1 && (
|
|
||||||
<HStack space={2} alignItems="center" className="ml-auto">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
disabled={page === 0}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span>
|
|
||||||
Page {page + 1} of {pages.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
disabled={page >= pages - 1}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
|
||||||
function wrap(text: string): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const line of text.split("\n")) {
|
|
||||||
if (line.length <= WRAP_CHARS) {
|
|
||||||
lines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
|
||||||
lines.push(line.slice(i, i + WRAP_CHARS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showLargeValueDialog({ text, sniffed }: Props) {
|
|
||||||
showDialog({
|
|
||||||
id: "large-value",
|
|
||||||
size: "lg",
|
|
||||||
className: "h-[calc(100vh-10rem)]",
|
|
||||||
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
|
||||||
// separate description and a row of its own for the button cost three bands to say this.
|
|
||||||
title: (
|
|
||||||
<HStack space={3} alignItems="center">
|
|
||||||
<span>
|
|
||||||
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
|
||||||
</span>
|
|
||||||
<LargeValueActions text={text} sniffed={sniffed} />
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
@@ -13,7 +14,6 @@ import type { ButtonProps } from "./core/Button";
|
|||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
||||||
|
|
||||||
@@ -52,18 +52,18 @@ function getDetail(
|
|||||||
leftSlot: <Icon icon="gift" />,
|
leftSlot: <Icon icon="gift" />,
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||||
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
||||||
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
|
onSelect: () => openUrl(data.data.changesUrl ?? ""),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
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,
|
||||||
onSelect: () => platform.openUrl(data.data.billingUrl),
|
onSelect: () => openUrl(data.data.billingUrl),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Enter License Key",
|
label: "Enter License Key",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -11,8 +12,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
|||||||
const src = useQuery({
|
const src = useQuery({
|
||||||
queryKey: ["local-image", srcPath],
|
queryKey: ["local-image", srcPath],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const p = await platform.files.resolveResource(srcPath);
|
const p = await resolveResource(srcPath);
|
||||||
return platform.files.url(p);
|
return convertFileSrc(p);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,660 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
HttpVersion,
|
|
||||||
InheritedBoolSetting,
|
|
||||||
InheritedHttpVersionSetting,
|
|
||||||
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_HTTP_VERSION,
|
|
||||||
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 { Select } from "./core/Select";
|
|
||||||
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 HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
|
||||||
type CookieSettingsPatch = {
|
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
|
||||||
};
|
|
||||||
type HttpSettingsPatch = {
|
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
|
||||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{supportsHttpSettings && (
|
|
||||||
<HttpVersionSettingRow
|
|
||||||
settingDefinition={SETTING_HTTP_VERSION}
|
|
||||||
setting={model.settingHttpVersion}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_HTTP_VERSION.modelKey,
|
|
||||||
model.settingHttpVersion,
|
|
||||||
)}
|
|
||||||
onChange={(settingHttpVersion) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingHttpVersion,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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 | HttpVersionSetting)[] = [];
|
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.push(model.settingValidateCertificates);
|
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
|
||||||
settings.push(
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
model.settingHttpVersion,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "HTTP/1.1", value: "http1" },
|
|
||||||
{ label: "HTTP/2", value: "http2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function HttpVersionSettingRow({
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
settingDefinition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
inheritedValue: HttpVersion;
|
|
||||||
setting: HttpVersionSetting;
|
|
||||||
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
|
||||||
onChange: (setting: HttpVersionSetting) => 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}>
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
options={HTTP_VERSION_OPTIONS}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
options={HTTP_VERSION_OPTIONS}
|
|
||||||
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: "settingHttpVersion",
|
|
||||||
fallback: HttpVersionSetting,
|
|
||||||
): HttpVersion;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: keyof WorkspaceSettings,
|
|
||||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
|
||||||
) {
|
|
||||||
for (const ancestor of ancestors) {
|
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
|
||||||
if (isInheritedSetting(setting)) {
|
|
||||||
if (setting.enabled === true) {
|
|
||||||
return setting.value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettings = Pick<
|
|
||||||
Workspace,
|
|
||||||
| "settingFollowRedirects"
|
|
||||||
| "settingHttpVersion"
|
|
||||||
| "settingRequestMessageSize"
|
|
||||||
| "settingRequestTimeout"
|
|
||||||
| "settingSendCookies"
|
|
||||||
| "settingStoreCookies"
|
|
||||||
| "settingValidateCertificates"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
|
||||||
keyof WorkspaceSettings,
|
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
|
||||||
>;
|
|
||||||
|
|
||||||
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,95 +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),
|
||||||
@@ -126,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,11 +1,10 @@
|
|||||||
|
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";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -30,22 +29,14 @@ 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={() => platform.openUrl(response.url)}
|
onClick={() => openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -24,9 +24,9 @@ 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={() => platform.openUrl(response.url)}
|
onClick={() => openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
@@ -18,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,11 +36,10 @@ export function SelectFile({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
hideLabel,
|
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
const filePath = await platform.dialog.open({
|
const filePath = await open({
|
||||||
title: directory ? "Select Folder" : "Select File",
|
title: directory ? "Select Folder" : "Select File",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
directory,
|
directory,
|
||||||
@@ -63,21 +62,32 @@ export function SelectFile({
|
|||||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
||||||
// as browser drag-n-drop.
|
// as browser drag-n-drop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return platform.window.onDragDrop((event) => {
|
let unlisten: (() => void) | undefined;
|
||||||
if (event.type === "over") {
|
const setup = async () => {
|
||||||
const p = event.position;
|
const webview = getCurrentWebviewWindow();
|
||||||
const r = ref.current?.getBoundingClientRect();
|
unlisten = await webview.onDragDropEvent((event) => {
|
||||||
if (r == null) return;
|
if (event.payload.type === "over") {
|
||||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
const p = event.payload.position;
|
||||||
setIsHovering(isOver);
|
const r = ref.current?.getBoundingClientRect();
|
||||||
} else if (event.type === "drop" && isHovering) {
|
if (r == null) return;
|
||||||
const p = event.paths[0];
|
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||||
if (p) onChange({ filePath: p, contentType: null });
|
console.log("IS OVER", isOver);
|
||||||
setIsHovering(false);
|
setIsHovering(isOver);
|
||||||
} else {
|
} else if (event.payload.type === "drop" && isHovering) {
|
||||||
setIsHovering(false);
|
console.log("User dropped", event.payload.paths);
|
||||||
}
|
const p = event.payload.paths[0];
|
||||||
});
|
if (p) onChange({ filePath: p, contentType: null });
|
||||||
|
setIsHovering(false);
|
||||||
|
} else {
|
||||||
|
console.log("File drop cancelled");
|
||||||
|
setIsHovering(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setup().catch(console.error);
|
||||||
|
return () => {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
}, [isHovering, onChange]);
|
}, [isHovering, onChange]);
|
||||||
|
|
||||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
||||||
@@ -85,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>
|
||||||
)}
|
)}
|
||||||
@@ -96,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}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { useSearch } from "@tanstack/react-router";
|
||||||
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -19,8 +21,6 @@ import { SettingsProxy } from "./SettingsProxy";
|
|||||||
import { SettingsTheme } from "./SettingsTheme";
|
import { SettingsTheme } from "./SettingsTheme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tab?: SettingsTabWithSubtab | null;
|
|
||||||
/** Set when Settings is in a dialog rather than owning a window. */
|
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,19 +43,25 @@ const tabs = [
|
|||||||
TAB_LICENSE,
|
TAB_LICENSE,
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
export type SettingsTab = (typeof tabs)[number];
|
||||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
|
||||||
|
|
||||||
export default function Settings({ tab, hide }: Props) {
|
export default function Settings({ hide }: Props) {
|
||||||
|
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||||
// Parse tab and subtab (e.g., "plugins:installed")
|
// Parse tab and subtab (e.g., "plugins:installed")
|
||||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
const plugins = useAtomValue(pluginsAtom);
|
||||||
const licenseCheck = useLicense();
|
const licenseCheck = useLicense();
|
||||||
|
|
||||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
// Close settings window on escape
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||||
useKeyPressEvent("Escape", async () => {
|
useKeyPressEvent("Escape", async () => {
|
||||||
if (hide == null) await platform.window.close();
|
if (hide != null) {
|
||||||
|
// It's being shown in a dialog, so close the dialog
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
// It's being shown in a window, so close the window
|
||||||
|
await getCurrentWebviewWindow().close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +75,7 @@ export default function Settings({ tab, hide }: Props) {
|
|||||||
onlyXWindowControl
|
onlyXWindowControl
|
||||||
size="md"
|
size="md"
|
||||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||||
osType={platform.osType()}
|
osType={type()}
|
||||||
hideWindowControls={settings.hideWindowControls}
|
hideWindowControls={settings.hideWindowControls}
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
useNativeTitlebar={settings.useNativeTitlebar}
|
||||||
interfaceScale={settings.interfaceScale}
|
interfaceScale={settings.interfaceScale}
|
||||||
@@ -79,15 +85,15 @@ export default function Settings({ tab, hide }: Props) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||||
>
|
>
|
||||||
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultValue={mainTab}
|
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 => ({
|
||||||
@@ -125,28 +131,28 @@ export default function Settings({ tab, 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) => (
|
||||||
|
|||||||
@@ -1,180 +1,175 @@
|
|||||||
|
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 { platform } from "@yaakapp-internal/platform";
|
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
||||||
import {
|
import { PlainInput } from "../core/PlainInput";
|
||||||
ModelSettingRowBoolean,
|
import { Select } from "../core/Select";
|
||||||
ModelSettingSelectControl,
|
import { Separator } from "../core/Separator";
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const checkForUpdates = useCheckForUpdates();
|
const checkForUpdates = useCheckForUpdates();
|
||||||
|
|
||||||
if (settings == null) {
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
<Checkbox
|
||||||
<SettingsSection title="Feedback">
|
checked={workspace.settingValidateCertificates}
|
||||||
<SettingRowBoolean
|
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||||
title="Prompt for feedback"
|
title="Validate TLS certificates"
|
||||||
description="Show rare one-time prompts asking how new features are working."
|
onChange={(settingValidateCertificates) =>
|
||||||
checked={settings.promptFeedback}
|
patchModel(workspace, { settingValidateCertificates })
|
||||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
<Checkbox
|
||||||
<DismissibleBanner
|
checked={workspace.settingFollowRedirects}
|
||||||
id="workspace-settings-moved-2026-06-30"
|
title="Follow redirects"
|
||||||
color="info"
|
onChange={(settingFollowRedirects) =>
|
||||||
className="w-full p-4 max-w-xl mr-auto"
|
patchModel(workspace, {
|
||||||
>
|
settingFollowRedirects,
|
||||||
<p>
|
})
|
||||||
Workspace specific settings have moved to{" "}
|
}
|
||||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
/>
|
||||||
menu.
|
</VStack>
|
||||||
</p>
|
|
||||||
</DismissibleBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
<Separator className="my-4" />
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
<Heading level={2}>App Info</Heading>
|
||||||
</SettingRow>
|
<KeyValueRows>
|
||||||
<SettingRow
|
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||||
title="Data Directory"
|
<KeyValueRow
|
||||||
description="Where Yaak stores application data."
|
label="Data Directory"
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
rightSlot={
|
||||||
>
|
<IconButton
|
||||||
<SettingValue
|
title={revealInFinderText}
|
||||||
value={appInfo.appDataDir}
|
icon="folder_open"
|
||||||
actions={[
|
size="2xs"
|
||||||
{
|
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => platform.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: () => platform.revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
}
|
||||||
</SettingsSection>
|
>
|
||||||
</SettingsList>
|
{appInfo.appLogDir}
|
||||||
|
</KeyValueRow>
|
||||||
|
</KeyValueRows>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
|||||||
<HotkeyRow
|
<HotkeyRow
|
||||||
key={action}
|
key={action}
|
||||||
action={action}
|
action={action}
|
||||||
currentKeys={hotkeys[action] ?? []}
|
currentKeys={hotkeys[action]}
|
||||||
defaultKeys={defaultHotkeys[action]}
|
defaultKeys={defaultHotkeys[action]}
|
||||||
onSave={async (keys) => {
|
onSave={async (keys) => {
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
const newHotkeys = { ...settings.hotkeys };
|
||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
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 { rpc } from "../../lib/rpc";
|
|
||||||
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,181 +38,163 @@ 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} />
|
|
||||||
{platform.osType() !== "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 rpc("cmd_restart");
|
await invokeCmd("cmd_restart");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
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>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import { differenceInDays } from "date-fns";
|
import { differenceInDays } from "date-fns";
|
||||||
@@ -5,13 +6,11 @@ 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";
|
||||||
import { PlainInput } from "../core/PlainInput";
|
import { PlainInput } from "../core/PlainInput";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
@@ -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={() => platform.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={() =>
|
||||||
platform.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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import type { Plugin } from "@yaakapp-internal/models";
|
import type { Plugin } from "@yaakapp-internal/models";
|
||||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||||
@@ -38,7 +39,6 @@ import { PlainInput } from "../core/PlainInput";
|
|||||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { SelectFile } from "../SelectFile";
|
import { SelectFile } from "../SelectFile";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface SettingsPluginsProps {
|
interface SettingsPluginsProps {
|
||||||
defaultSubtab?: string;
|
defaultSubtab?: string;
|
||||||
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
icon="help"
|
icon="help"
|
||||||
title="View documentation"
|
title="View documentation"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
@@ -6,13 +7,11 @@ 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";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
export function SettingsDropdown() {
|
export function SettingsDropdown() {
|
||||||
const exportData = useExportData();
|
const exportData = useExportData();
|
||||||
@@ -62,7 +61,7 @@ export function SettingsDropdown() {
|
|||||||
{
|
{
|
||||||
label: "Create Run Button",
|
label: "Create Run Button",
|
||||||
leftSlot: <Icon icon="rocket" />,
|
leftSlot: <Icon icon="rocket" />,
|
||||||
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
|
onSelect: () => openUrl("https://yaak.app/button/new"),
|
||||||
},
|
},
|
||||||
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
||||||
{
|
{
|
||||||
@@ -77,27 +76,26 @@ 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"),
|
||||||
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
hidden: appInfo.cliVersion != null,
|
hidden: appInfo.cliVersion != null,
|
||||||
leftSlot: <Icon icon="square_terminal" />,
|
leftSlot: <Icon icon="square_terminal" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
|
onSelect: () => openUrl("https://yaak.app/docs/cli"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Feedback",
|
label: "Feedback",
|
||||||
leftSlot: <Icon icon="chat" />,
|
leftSlot: <Icon icon="chat" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
|
onSelect: () => openUrl("https://yaak.app/feedback"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Changelog",
|
label: "Changelog",
|
||||||
leftSlot: <Icon icon="cake" />,
|
leftSlot: <Icon icon="cake" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ import { getFolderActions } from "../hooks/useFolderActions";
|
|||||||
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
import { useHotKey } from "../hooks/useHotKey";
|
||||||
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { useListenToTauriEvent } from "../hooks/useListenToTauriEvent";
|
||||||
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
|
||||||
import { getModelAncestors } from "../hooks/useModelAncestors";
|
import { getModelAncestors } from "../hooks/useModelAncestors";
|
||||||
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||||
@@ -65,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 {
|
||||||
@@ -82,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";
|
||||||
@@ -112,8 +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);
|
||||||
@@ -132,17 +127,11 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
if (!didFocus) filterRef.current?.focus();
|
if (!didFocus) filterRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus new sidebar models created by the user in this window. Writes from other
|
// Focus any new sidebar models when created
|
||||||
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
|
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
|
||||||
// the selection.
|
if (!isSidebarLeafModel(payload.model)) return;
|
||||||
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
|
if (!(payload.change.type === "upsert" && payload.change.created)) return;
|
||||||
for (const payload of payloads) {
|
treeRef.current?.selectItem(payload.model.id, true);
|
||||||
if (payload.updateSource.type !== "window") continue;
|
|
||||||
if (payload.updateSource.label !== platform.window.label) continue;
|
|
||||||
if (!isSidebarLeafModel(payload.model)) continue;
|
|
||||||
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
|
|
||||||
treeRef.current?.selectItem(payload.model.id, true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -238,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();
|
||||||
});
|
});
|
||||||
@@ -263,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(
|
||||||
@@ -596,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}
|
||||||
@@ -672,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
|
||||||
@@ -732,11 +679,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized so route navigations (which re-render the workspace layout) don't
|
export default Sidebar;
|
||||||
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
|
|
||||||
// very expensive: it re-renders DndContext, whose context churn re-renders
|
|
||||||
// every visible TreeItem regardless of their memo comparators.
|
|
||||||
export default memo(Sidebar);
|
|
||||||
|
|
||||||
function getGitContextMenuItems({
|
function getGitContextMenuItems({
|
||||||
items,
|
items,
|
||||||
@@ -843,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);
|
||||||
@@ -905,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;
|
||||||
@@ -921,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) {
|
||||||
@@ -981,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) => {
|
||||||
|
|||||||
@@ -1,82 +1,23 @@
|
|||||||
|
import { readDir } from "@tauri-apps/plugin-fs";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
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 { 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";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
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 platform.files.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"
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
pairs: EditablePair[];
|
pairs: HttpRequest["headers"];
|
||||||
stateKey: PairEditorProps["stateKey"];
|
stateKey: PairEditorProps["stateKey"];
|
||||||
onChange: PairEditorProps["onChange"];
|
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||||
import {
|
import { patchModel } from "@yaakapp-internal/models";
|
||||||
flushAllModelWrites,
|
|
||||||
getModel,
|
|
||||||
patchModel,
|
|
||||||
patchModelDebounced,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -13,7 +8,8 @@ import type { CSSProperties } from "react";
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||||
|
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
@@ -22,22 +18,22 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|||||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
import { generateId } from "../lib/generateId";
|
||||||
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";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
|
import type { Pair } from "./core/PairEditor";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
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";
|
||||||
|
|
||||||
@@ -52,16 +48,18 @@ 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";
|
||||||
|
|
||||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||||
// actually changes. The active request's own URL is included, but exact matches
|
const activeRequestId = get(activeRequestIdAtom);
|
||||||
// are filtered out at completion time by genericCompletion.
|
const requests = get(allRequestsAtom);
|
||||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
return requests
|
||||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
.filter((r) => r.id !== activeRequestId)
|
||||||
);
|
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||||
|
|
||||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -71,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(
|
||||||
@@ -82,33 +79,22 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||||
// value detaches from the placeholder.
|
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
(m) => m[1] ?? "",
|
||||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
);
|
||||||
// patch its parameter list back over newer edits.
|
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||||
const handleRenamePathPlaceholder = useCallback(
|
const items: Pair[] = [...nonEmptyParameters];
|
||||||
(oldName: string, newName: string) => {
|
for (const name of placeholderNames) {
|
||||||
const request = getModel("websocket_request", activeRequestId);
|
const item = items.find((p) => p.name === name);
|
||||||
if (request == null) return false;
|
if (item) {
|
||||||
|
item.readOnlyName = true;
|
||||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
} else {
|
||||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||||
void patchModel(request, patch);
|
}
|
||||||
return true;
|
}
|
||||||
},
|
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||||
[activeRequestId],
|
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||||
);
|
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
|
||||||
() =>
|
|
||||||
derivePathPlaceholderPairs(
|
|
||||||
activeRequest.url,
|
|
||||||
activeRequest.urlParameters,
|
|
||||||
handleRenamePathPlaceholder,
|
|
||||||
),
|
|
||||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
return [
|
return [
|
||||||
@@ -123,31 +109,34 @@ 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);
|
||||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => getUrlCompletionConfig(autocompleteUrls),
|
() => ({
|
||||||
|
minMatch: 3,
|
||||||
|
options:
|
||||||
|
autocompleteUrls.length > 0
|
||||||
|
? autocompleteUrls
|
||||||
|
: [
|
||||||
|
{ label: "http://", type: "constant" },
|
||||||
|
{ label: "https://", type: "constant" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
const handleConnect = useCallback(async () => {
|
||||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
|
||||||
await connectWebsocket({
|
await connectWebsocket({
|
||||||
requestId: activeRequest.id,
|
requestId: activeRequest.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -157,7 +146,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (connection == null) return;
|
if (connection == null) return;
|
||||||
await flushAllModelWrites(); // The backend reads the message from the DB
|
|
||||||
await sendWebsocket({
|
await sendWebsocket({
|
||||||
connectionId: connection?.id,
|
connectionId: connection?.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -170,7 +158,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
(url: string) => patchModel(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -222,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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -241,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}
|
||||||
>
|
>
|
||||||
@@ -254,7 +242,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -262,7 +250,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_MESSAGE}>
|
<TabContent value={TAB_MESSAGE}>
|
||||||
@@ -274,13 +262,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
heightMode={fullHeight ? "full" : "auto"}
|
heightMode={fullHeight ? "full" : "auto"}
|
||||||
defaultValue={activeRequest.message}
|
defaultValue={activeRequest.message}
|
||||||
language={messageLanguage}
|
language={messageLanguage}
|
||||||
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
onChange={(message) => patchModel(activeRequest, { message })}
|
||||||
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
|
||||||
@@ -288,10 +273,10 @@ 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) => patchModelDebounced(activeRequest, { name })}
|
onChange={(name) => patchModel(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
@@ -299,7 +284,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
defaultValue={activeRequest.description}
|
defaultValue={activeRequest.description}
|
||||||
stateKey={`description.${activeRequest.id}`}
|
stateKey={`description.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
onChange={(description) => patchModel(activeRequest, { description })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|||||||
@@ -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 !== ""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -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";
|
||||||
@@ -53,7 +51,7 @@ export function Workspace() {
|
|||||||
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const osType = platform.osType();
|
const osType = type();
|
||||||
const [width, setWidth] = useSidebarWidth();
|
const [width, setWidth] = useSidebarWidth();
|
||||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||||
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -14,7 +16,6 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
|||||||
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
||||||
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { importData } from "../lib/importData";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { revealInFinderText } from "../lib/reveal";
|
import { revealInFinderText } from "../lib/reveal";
|
||||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||||
@@ -25,7 +26,6 @@ import { Icon } from "@yaakapp-internal/ui";
|
|||||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
label: "Open Folder",
|
label: "Open Folder",
|
||||||
leftSlot: <Icon icon="folder_open" />,
|
leftSlot: <Icon icon="folder_open" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
const dir = await platform.dialog.open({
|
const dir = await open({
|
||||||
title: "Select Workspace Directory",
|
title: "Select Workspace Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -90,11 +90,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
onSelect: openCloneGitRepositoryDialog,
|
onSelect: openCloneGitRepositoryDialog,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Import Data",
|
|
||||||
leftSlot: <Icon icon="folder_input" />,
|
|
||||||
onSelect: () => importData.mutate(),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -119,7 +114,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="folder_symlink" />,
|
leftSlot: <Icon icon="folder_symlink" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
if (workspaceMeta?.settingSyncDir == null) return;
|
if (workspaceMeta?.settingSyncDir == null) return;
|
||||||
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
|
await revealItemInDir(workspaceMeta.settingSyncDir);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,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 (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CookieDropdown } from "./CookieDropdown";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
||||||
import { ImportCurl } from "./ImportCurl";
|
import { ImportCurlButton } from "./ImportCurlButton";
|
||||||
import { LicenseBadge } from "./LicenseBadge";
|
import { LicenseBadge } from "./LicenseBadge";
|
||||||
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
||||||
import { SettingsDropdown } from "./SettingsDropdown";
|
import { SettingsDropdown } from "./SettingsDropdown";
|
||||||
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
|
|||||||
<RecentRequestsDropdown />
|
<RecentRequestsDropdown />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
||||||
<ImportCurl />
|
<ImportCurlButton />
|
||||||
{showEncryptionSetup ? (
|
{showEncryptionSetup ? (
|
||||||
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
||||||
Enter Encryption Key
|
Enter Encryption Key
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user