mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-06 01:47:14 +02:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5919fae739 | ||
|
|
a9cccb21b8 | ||
|
|
1c0435e3ff | ||
|
|
a629a1fa79 | ||
|
|
dde8d61b4b | ||
|
|
08a64b6938 | ||
|
|
560c4667e4 | ||
|
|
21f775741a | ||
|
|
44a331929f | ||
|
|
7670ab007f | ||
|
|
be34dfe74a | ||
|
|
e4103f1a4a | ||
|
|
7f4eedd630 | ||
|
|
49659a3da9 |
+7
-7
@@ -8,7 +8,7 @@ Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core R
|
|||||||
|
|
||||||
```
|
```
|
||||||
crates/ # Core crates - should NOT depend on Tauri
|
crates/ # Core crates - should NOT depend on Tauri
|
||||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
|
||||||
crates-cli/ # CLI crate (yaak-cli)
|
crates-cli/ # CLI crate (yaak-cli)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
|
|
||||||
### 1. Folder Restructure
|
### 1. Folder Restructure
|
||||||
|
|
||||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
|
||||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
||||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
||||||
|
|
||||||
@@ -50,14 +50,14 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
||||||
4. Initialize managers in yaak-app's `.setup()` block
|
4. Initialize managers in yaak-app's `.setup()` block
|
||||||
5. Remove `tauri` from Cargo.toml dependencies
|
5. Remove `tauri` from Cargo.toml dependencies
|
||||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
|
||||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
|
||||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
|
||||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
||||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
||||||
|
|
||||||
@@ -79,5 +79,5 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
||||||
- Run `npm run client:dev` to test the Tauri app still works
|
- Run `npm run app-dev` to test the Tauri app still works
|
||||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||||
|
|||||||
@@ -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
|
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
|
||||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
|
||||||
**/bindings/* linguist-generated=true
|
**/bindings/* linguist-generated=true
|
||||||
crates/yaak-templates/pkg/* linguist-generated=true
|
crates/yaak-templates/pkg/* linguist-generated=true
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -169,8 +125,8 @@ jobs:
|
|||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Sign vendored binaries with hardened runtime and their specific entitlements
|
# Sign vendored binaries with hardened runtime and their specific entitlements
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -194,30 +150,12 @@ 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
|
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
|
||||||
args: "${{ matrix.args }}"
|
|
||||||
|
|
||||||
- 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)
|
||||||
@@ -233,9 +171,7 @@ jobs:
|
|||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
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
|
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
|
||||||
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"
|
||||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -46,8 +45,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: vendored-assets
|
name: vendored-assets
|
||||||
path: |
|
path: |
|
||||||
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
|
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
|
||||||
crates-tauri/yaak-app-client/vendored/plugins
|
crates-tauri/yaak-app/vendored/plugins
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-binaries:
|
build-binaries:
|
||||||
@@ -108,7 +107,7 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: vendored-assets
|
name: vendored-assets
|
||||||
path: crates-tauri/yaak-app-client/vendored
|
path: crates-tauri/yaak-app/vendored
|
||||||
|
|
||||||
- name: Set CLI build version
|
- name: Set CLI build version
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -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 🛎️
|
||||||
|
|||||||
+1
-3
@@ -39,8 +39,7 @@ codebook.toml
|
|||||||
target
|
target
|
||||||
|
|
||||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
crates-tauri/yaak-app/tauri.worktree.conf.json
|
||||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
|
||||||
|
|
||||||
# Tauri auto-generated permission files
|
# Tauri auto-generated permission files
|
||||||
**/permissions/autogenerated
|
**/permissions/autogenerated
|
||||||
@@ -58,4 +57,3 @@ flatpak/node-sources.json
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
**/bindings/**
|
**/bindings/**
|
||||||
**/routeTree.gen.ts
|
|
||||||
crates/yaak-templates/pkg/**
|
crates/yaak-templates/pkg/**
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/bindings/**",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"apps/yaak-client/routeTree.gen.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
vp lint
|
vp lint
|
||||||
vp staged
|
|
||||||
|
|||||||
@@ -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
+456
-1674
File diff suppressed because it is too large
Load Diff
+5
-41
@@ -2,11 +2,6 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
"crates/yaak-commands",
|
|
||||||
# Common/foundation crates
|
|
||||||
"crates/common/yaak-database",
|
|
||||||
"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 +9,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,24 +16,15 @@ 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",
|
|
||||||
# Proxy-specific crates
|
|
||||||
"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
|
||||||
"crates-tauri/yaak-app-client",
|
"crates-tauri/yaak-app",
|
||||||
"crates-tauri/yaak-app-proxy",
|
|
||||||
"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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -54,33 +39,22 @@ 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.9.5"
|
||||||
"common-controls-v6",
|
tauri-plugin = "2.5.2"
|
||||||
"compression",
|
tauri-plugin-dialog = "2.4.2"
|
||||||
"dynamic-acl",
|
tauri-plugin-shell = "2.3.3"
|
||||||
] }
|
|
||||||
tauri-plugin = "2.6.1"
|
|
||||||
tauri-plugin-dialog = { version = "2.7.1", default-features = false }
|
|
||||||
tauri-plugin-shell = "2.3.5"
|
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
ts-rs = "11.1.0"
|
ts-rs = "11.1.0"
|
||||||
|
|
||||||
# Internal crates - common/foundation
|
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
|
||||||
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" }
|
||||||
@@ -89,22 +63,12 @@ yaak-templates = { path = "crates/yaak-templates" }
|
|||||||
yaak-tls = { path = "crates/yaak-tls" }
|
yaak-tls = { path = "crates/yaak-tls" }
|
||||||
yaak-ws = { path = "crates/yaak-ws" }
|
yaak-ws = { path = "crates/yaak-ws" }
|
||||||
yaak-api = { path = "crates/yaak-api" }
|
yaak-api = { path = "crates/yaak-api" }
|
||||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
|
||||||
|
|
||||||
# Internal crates - proxy
|
|
||||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|
||||||
|
|
||||||
# Internal crates - Tauri-specific
|
# Internal crates - Tauri-specific
|
||||||
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" }
|
|
||||||
|
|
||||||
[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"]
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -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`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
|
||||||
mutationKey: ["open_settings"],
|
|
||||||
mutationFn: async (tab) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
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({
|
|
||||||
to: "/workspaces/$workspaceId/settings",
|
|
||||||
params: { workspaceId },
|
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
await rpc("cmd_new_child_window", {
|
|
||||||
url: location.href,
|
|
||||||
label: "settings",
|
|
||||||
title: "Yaak Settings",
|
|
||||||
innerSize: [750, 600],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
import type { Cookie } from "@yaakapp-internal/models";
|
|
||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
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 {
|
|
||||||
Icon,
|
|
||||||
SplitLayout,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
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 {
|
|
||||||
cookieJarId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
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) {
|
|
||||||
return <div>No cookie jar selected</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 (
|
|
||||||
<TableRow
|
|
||||||
key={key}
|
|
||||||
className={classNames(
|
|
||||||
"group/tr cursor-default",
|
|
||||||
isSelected && "[&_td]:bg-surface-highlight",
|
|
||||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCookieKey(key);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={style} className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CookieDialog.show = (cookieJarId: string | null) => {
|
|
||||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
|
||||||
if (cookieJar == null) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-jar",
|
|
||||||
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
|
||||||
title: "Invalid Cookie Jar",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "cookies",
|
|
||||||
title: `${cookieJar.name} Cookies`,
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows selectable>
|
|
||||||
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
|
||||||
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
{cookie.sameSite && (
|
|
||||||
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
|
||||||
)}
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieEditor({
|
|
||||||
cookie,
|
|
||||||
expiresInputValue,
|
|
||||||
onChange,
|
|
||||||
onExpiresInputChange,
|
|
||||||
}: {
|
|
||||||
cookie: Cookie;
|
|
||||||
expiresInputValue: string;
|
|
||||||
onChange: (cookie: Cookie) => void;
|
|
||||||
onExpiresInputChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const sessionCookie = cookie.expires === "SessionEnd";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows>
|
|
||||||
<CookieKeyValueRow align="middle" label="Name">
|
|
||||||
<CookieTextInput
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
|
||||||
value={cookie.name}
|
|
||||||
onChange={(name) => onChange({ ...cookie, name })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value">
|
|
||||||
<CookieTextarea
|
|
||||||
value={cookie.value}
|
|
||||||
onChange={(value) => onChange({ ...cookie, value })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Domain">
|
|
||||||
<CookieTextInput
|
|
||||||
required
|
|
||||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
|
||||||
value={cookieDomainInputValue(cookie)}
|
|
||||||
placeholder="example.com"
|
|
||||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Path">
|
|
||||||
<CookieTextInput
|
|
||||||
value={cookie.path}
|
|
||||||
placeholder="/"
|
|
||||||
onChange={(path) => onChange({ ...cookie, path })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={sessionCookie}
|
|
||||||
title="Session cookie"
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
onChange({ ...cookie, expires: "SessionEnd" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInput =
|
|
||||||
cookieExpiresFromInput(expiresInputValue) == null
|
|
||||||
? defaultCookieExpiresInputValue()
|
|
||||||
: expiresInputValue;
|
|
||||||
|
|
||||||
onExpiresInputChange(expiresInput);
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
expires: cookieExpiresFromInput(expiresInput)!,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CookieTextInput
|
|
||||||
value={sessionCookie ? "" : expiresInputValue}
|
|
||||||
disabled={sessionCookie}
|
|
||||||
onChange={(value) => {
|
|
||||||
onExpiresInputChange(value);
|
|
||||||
|
|
||||||
const expires = cookieExpiresFromInput(value);
|
|
||||||
if (expires != null) {
|
|
||||||
onChange({ ...cookie, expires });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="HTTP Only">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="HTTP Only"
|
|
||||||
checked={cookie.httpOnly}
|
|
||||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Secure">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="Secure"
|
|
||||||
checked={cookie.secure}
|
|
||||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Same Site">
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name="cookie-same-site"
|
|
||||||
label="Same Site"
|
|
||||||
value={cookie.sameSite ?? ""}
|
|
||||||
size="xs"
|
|
||||||
className="w-full"
|
|
||||||
options={[
|
|
||||||
{ label: "n/a", value: "" },
|
|
||||||
{ label: "Lax", value: "Lax" },
|
|
||||||
{ label: "Strict", value: "Strict" },
|
|
||||||
{ label: "None", value: "None" },
|
|
||||||
]}
|
|
||||||
onChange={(sameSite) =>
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
|
||||||
return <KeyValueRow labelClassName={classNames("w-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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useLocalStorage } from "react-use";
|
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
importFile: (filePath: string) => Promise<void>;
|
|
||||||
importUrl: (url: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
|
||||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", 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) => {
|
|
||||||
setSource(value);
|
|
||||||
// Remount the input so it shows the path of the newly-picked file
|
|
||||||
setForceUpdateKey((k) => k + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
|
||||||
useEffect(() => {
|
|
||||||
return platform.window.onDragDrop((event) => {
|
|
||||||
if (event.type === "over") {
|
|
||||||
const p = event.position;
|
|
||||||
const r = ref.current?.getBoundingClientRect();
|
|
||||||
if (r == null) return;
|
|
||||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
|
||||||
} else if (event.type === "drop" && isHovering) {
|
|
||||||
const p = event.paths[0];
|
|
||||||
if (p) selectSource(p);
|
|
||||||
setIsHovering(false);
|
|
||||||
} else {
|
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isHovering, setSource]);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
|
||||||
if (selected == null) return;
|
|
||||||
selectSource(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
if (filePath != null) {
|
|
||||||
await importFile(filePath);
|
|
||||||
} else {
|
|
||||||
await importUrl(trimmedSource);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<VStack space={2}>
|
|
||||||
<PlainInput
|
|
||||||
label="Or enter a file path or URL"
|
|
||||||
size="sm"
|
|
||||||
placeholder="https://example.com/openapi.json"
|
|
||||||
defaultValue={source ?? ""}
|
|
||||||
forceUpdateKey={String(forceUpdateKey)}
|
|
||||||
onChange={setSource}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
disabled={trimmedSource === "" || isLoading}
|
|
||||||
isLoading={isLoading}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleImport}
|
|
||||||
>
|
|
||||||
{isLoading ? "Importing" : "Import"}
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
|
||||||
import { deleteModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import {
|
|
||||||
differenceInHours,
|
|
||||||
differenceInMinutes,
|
|
||||||
format,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
} from "date-fns";
|
|
||||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
connections: GrpcConnection[];
|
|
||||||
activeConnection: GrpcConnection;
|
|
||||||
onPinnedConnectionId: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentGrpcConnectionsDropdown({
|
|
||||||
activeConnection,
|
|
||||||
connections,
|
|
||||||
onPinnedConnectionId,
|
|
||||||
}: Props) {
|
|
||||||
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
|
|
||||||
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 (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Clear Connection",
|
|
||||||
onSelect: () => deleteModel(activeConnection),
|
|
||||||
disabled: connections.length === 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
|
|
||||||
onSelect: deleteAllConnections.mutate,
|
|
||||||
hidden: connections.length <= 1,
|
|
||||||
disabled: connections.length === 0,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "History" },
|
|
||||||
...connectionHistoryItems,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
title="Show connection history"
|
|
||||||
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
|
|
||||||
className="m-0.5 text-text-subtle"
|
|
||||||
size="sm"
|
|
||||||
iconSize="md"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { deleteModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import {
|
|
||||||
differenceInHours,
|
|
||||||
differenceInMinutes,
|
|
||||||
format,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
} from "date-fns";
|
|
||||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
responses: HttpResponse[];
|
|
||||||
activeResponse: HttpResponse;
|
|
||||||
onPinnedResponseId: (id: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RecentHttpResponsesDropdown = function ResponsePane({
|
|
||||||
activeResponse,
|
|
||||||
responses,
|
|
||||||
onPinnedResponseId,
|
|
||||||
}: Props) {
|
|
||||||
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 responseHistoryItems: DropdownItem[] = [];
|
|
||||||
let lastHistoryGroup: string | null = null;
|
|
||||||
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 (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: () => deleteModel(activeResponse),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete all",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: deleteAllResponses.mutate,
|
|
||||||
disabled: responses.length === 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Unpin Response",
|
|
||||||
onSelect: () => onPinnedResponseId(activeResponse.id),
|
|
||||||
leftSlot: <Icon icon="unpin" />,
|
|
||||||
hidden: latestResponseId === activeResponse.id,
|
|
||||||
disabled: responses.length === 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
hidden: dismissedMovedActions === true,
|
|
||||||
label: (
|
|
||||||
<DismissibleBanner
|
|
||||||
id={movedActionsBannerId}
|
|
||||||
color="info"
|
|
||||||
size="xs"
|
|
||||||
className="max-w-72"
|
|
||||||
>
|
|
||||||
<p>Copy and save actions moved to the Response tab menu.</p>
|
|
||||||
</DismissibleBanner>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "separator",
|
|
||||||
label: "Recent",
|
|
||||||
},
|
|
||||||
...responseHistoryItems,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
title="Show response history"
|
|
||||||
icon={activeResponse?.id === latestResponseId ? "history" : "pin"}
|
|
||||||
className="m-0.5 text-text-subtle"
|
|
||||||
size="sm"
|
|
||||||
iconSize="md"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
|
||||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import {
|
|
||||||
differenceInHours,
|
|
||||||
differenceInMinutes,
|
|
||||||
format,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
} from "date-fns";
|
|
||||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
connections: WebsocketConnection[];
|
|
||||||
activeConnection: WebsocketConnection;
|
|
||||||
onPinnedConnectionId: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentWebsocketConnectionsDropdown({
|
|
||||||
activeConnection,
|
|
||||||
connections,
|
|
||||||
onPinnedConnectionId,
|
|
||||||
}: Props) {
|
|
||||||
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 (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Clear Connection",
|
|
||||||
onSelect: () => deleteModel(activeConnection),
|
|
||||||
disabled: connections.length === 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
|
|
||||||
onSelect: () => {
|
|
||||||
const request = getModel("websocket_request", activeConnection.requestId);
|
|
||||||
if (request != null) {
|
|
||||||
deleteWebsocketConnections.mutate(request);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hidden: connections.length <= 1,
|
|
||||||
disabled: connections.length === 0,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "History" },
|
|
||||||
...connectionHistoryItems,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
title="Show connection history"
|
|
||||||
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
|
|
||||||
className="m-0.5 text-text-subtle"
|
|
||||||
size="sm"
|
|
||||||
iconSize="md"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
|
||||||
import { appInfo } from "../../lib/appInfo";
|
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingSelectControl,
|
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const checkForUpdates = useCheckForUpdates();
|
|
||||||
|
|
||||||
if (settings == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showWorkspaceSettingsMovedBanner =
|
|
||||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div>
|
|
||||||
<Heading>General</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Configure general settings for update behavior and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 mb-5">
|
|
||||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<CargoFeature feature="updater">
|
|
||||||
<SettingsSection title="Updates">
|
|
||||||
<SettingRow
|
|
||||||
title="Update Channel"
|
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
|
||||||
<ModelSettingSelectControl
|
|
||||||
model={settings}
|
|
||||||
modelKey="updateChannel"
|
|
||||||
label="Update Channel"
|
|
||||||
selectClassName="w-full!"
|
|
||||||
options={[
|
|
||||||
{ label: "Stable", value: "stable" },
|
|
||||||
{ label: "Beta", value: "beta" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Update Behavior"
|
|
||||||
description="Choose whether updates are installed automatically or manually."
|
|
||||||
name="autoupdate"
|
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, { autoupdate: v === "auto" })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "Manual", value: "manual" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="autoDownloadUpdates"
|
|
||||||
title="Automatically download updates"
|
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
|
||||||
disabled
|
|
||||||
checked={false}
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<SettingsSection title="Feedback">
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Prompt for feedback"
|
|
||||||
description="Show rare one-time prompts asking how new features are working."
|
|
||||||
checked={settings.promptFeedback}
|
|
||||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
|
||||||
<DismissibleBanner
|
|
||||||
id="workspace-settings-moved-2026-06-30"
|
|
||||||
color="info"
|
|
||||||
className="w-full p-4 max-w-xl mr-auto"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Workspace specific settings have moved to{" "}
|
|
||||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
|
||||||
menu.
|
|
||||||
</p>
|
|
||||||
</DismissibleBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Data Directory"
|
|
||||||
description="Where Yaak stores application data."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Logs Directory"
|
|
||||||
description="Where Yaak writes application logs."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appLogDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { rpc } from "../../lib/rpc";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingSelectControl,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
|
||||||
|
|
||||||
const fontSizeOptions = [
|
|
||||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
|
||||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
|
||||||
|
|
||||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
|
||||||
{ value: "default", label: "Default" },
|
|
||||||
{ value: "vim", label: "Vim" },
|
|
||||||
{ value: "vscode", label: "VSCode" },
|
|
||||||
{ value: "emacs", label: "Emacs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsInterface() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const fonts = useFonts();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Interface</Heading>
|
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Workspaces">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Open workspace behavior"
|
|
||||||
description="Choose what happens when opening another workspace."
|
|
||||||
name="switchWorkspaceBehavior"
|
|
||||||
value={
|
|
||||||
settings.openWorkspaceNewWindow === true
|
|
||||||
? "new"
|
|
||||||
: settings.openWorkspaceNewWindow === false
|
|
||||||
? "current"
|
|
||||||
: "ask"
|
|
||||||
}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
|
||||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
|
||||||
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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Fonts">
|
|
||||||
<SettingRow
|
|
||||||
title="Interface font"
|
|
||||||
description="Font used for Yaak interface controls."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="uiFont"
|
|
||||||
label="Interface font"
|
|
||||||
selectClassName="w-72!"
|
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { interfaceFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="interfaceFontSize"
|
|
||||||
label="Interface Font Size"
|
|
||||||
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}
|
|
||||||
modelKey="editorSoftWrap"
|
|
||||||
title="Wrap editor lines"
|
|
||||||
description="Wrap long lines in request and response editors."
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="coloredMethods"
|
|
||||||
title="Colorize request methods"
|
|
||||||
description="Use method-specific colors for HTTP request methods."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
|
||||||
<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">
|
|
||||||
<LicenseSettings settings={settings} />
|
|
||||||
</CargoFeature>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
title="Native title bar"
|
|
||||||
description="Use the operating system's standard title bar and window controls."
|
|
||||||
controlClassName="gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
checked={nativeTitlebar}
|
|
||||||
title="Native title bar"
|
|
||||||
onChange={setNativeTitlebar}
|
|
||||||
/>
|
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
|
||||||
await rpc("cmd_restart");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply and Restart
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
|
||||||
const license = useLicense();
|
|
||||||
if (license.check.data?.status !== "personal_use") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection title="License">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.hideLicenseBadge}
|
|
||||||
title="Hide personal use badge"
|
|
||||||
description="Hide the personal-use badge from the interface."
|
|
||||||
onChange={async (hideLicenseBadge) => {
|
|
||||||
if (hideLicenseBadge) {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-license-badge",
|
|
||||||
title: "Confirm Personal Use",
|
|
||||||
confirmText: "Confirm",
|
|
||||||
description: (
|
|
||||||
<VStack space={3}>
|
|
||||||
<p>Hey there 👋🏼</p>
|
|
||||||
<p>
|
|
||||||
Yaak is free for personal projects and learning.{" "}
|
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
|
||||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
|
||||||
Purchase a License →
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
requireTyping: "Personal Use",
|
|
||||||
color: "info",
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import {
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingRowText,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsProxy() {
|
|
||||||
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 (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Proxy</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
|
||||||
traffic, or routing through specific infrastructure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Proxy">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Proxy"
|
|
||||||
description="Choose how Yaak should discover or use proxy settings."
|
|
||||||
name="proxy"
|
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "automatic") {
|
|
||||||
await patchModel(settings, { proxy: undefined });
|
|
||||||
} else if (v === "enabled") {
|
|
||||||
await patchModel(settings, { proxy });
|
|
||||||
} 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" },
|
|
||||||
]}
|
|
||||||
selectClassName="w-64!"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{settings.proxy?.type === "enabled" && (
|
|
||||||
<>
|
|
||||||
<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
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsList>
|
|
||||||
</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: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
|
||||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
|
||||||
import type { ButtonProps } from "../core/Button";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import type { SelectProps } from "../core/Select";
|
|
||||||
import {
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
|
||||||
|
|
||||||
const buttonColors: ButtonProps["color"][] = [
|
|
||||||
"primary",
|
|
||||||
"info",
|
|
||||||
"success",
|
|
||||||
"notice",
|
|
||||||
"warning",
|
|
||||||
"danger",
|
|
||||||
"secondary",
|
|
||||||
"default",
|
|
||||||
];
|
|
||||||
|
|
||||||
const icons: IconProps["icon"][] = [
|
|
||||||
"info",
|
|
||||||
"box",
|
|
||||||
"update",
|
|
||||||
"alert_triangle",
|
|
||||||
"arrow_big_right_dash",
|
|
||||||
"download",
|
|
||||||
"copy",
|
|
||||||
"magic_wand",
|
|
||||||
"settings",
|
|
||||||
"trash",
|
|
||||||
"sparkles",
|
|
||||||
"pencil",
|
|
||||||
"paste",
|
|
||||||
"search",
|
|
||||||
"send_horizontal",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsTheme() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const appearance = useResolvedAppearance();
|
|
||||||
const activeTheme = useResolvedTheme();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => !theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Theme</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Make Yaak your own by selecting a theme, or{" "}
|
|
||||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
|
||||||
Create Your Own
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Theme">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="appearance"
|
|
||||||
title="Appearance"
|
|
||||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "system" },
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="lightTheme"
|
|
||||||
title="Light theme"
|
|
||||||
description="Theme used when Yaak is in light mode."
|
|
||||||
value={activeTheme.data.light.id}
|
|
||||||
options={lightThemes}
|
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="darkTheme"
|
|
||||||
title="Dark theme"
|
|
||||||
description="Theme used when Yaak is in dark mode."
|
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
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}>
|
|
||||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
|
||||||
<em>(preview)</em>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={1.5} className="w-full">
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
variant="border"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
<Suspense>
|
|
||||||
<Editor
|
|
||||||
defaultValue={[
|
|
||||||
"let foo = { // Demo code editor",
|
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
|
||||||
" baz: [1, 10.2, null, false, true],",
|
|
||||||
"};",
|
|
||||||
].join("\n")}
|
|
||||||
heightMode="auto"
|
|
||||||
language="javascript"
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</VStack>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
|
||||||
layout?: "form" | "settings";
|
|
||||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
|
||||||
onCreateNewWorkspace: () => void;
|
|
||||||
value: { filePath: string | null; initGit?: boolean };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncToFilesystemSetting({
|
|
||||||
layout = "form",
|
|
||||||
onChange,
|
|
||||||
onCreateNewWorkspace,
|
|
||||||
value,
|
|
||||||
}: SyncToFilesystemSettingProps) {
|
|
||||||
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 (
|
|
||||||
<VStack className="w-full my-2" space={3}>
|
|
||||||
{syncDir && (
|
|
||||||
<Banner color="notice" className="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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectFile
|
|
||||||
directory
|
|
||||||
label="Local directory sync"
|
|
||||||
size="xs"
|
|
||||||
noun="Directory"
|
|
||||||
help="Sync data to a folder for backup and Git integration."
|
|
||||||
filePath={value.filePath}
|
|
||||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
|
||||||
<Checkbox
|
|
||||||
checked={value.initGit}
|
|
||||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
|
||||||
title="Initialize Git Repo"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parseBulkPairLine } from "./BulkPairEditor";
|
|
||||||
|
|
||||||
describe("parseBulkPairLine", () => {
|
|
||||||
test("parses colon-space pairs as name and value", () => {
|
|
||||||
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
|
||||||
enabled: true,
|
|
||||||
name: "foo",
|
|
||||||
value: "bar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves colon-without-space lines as a name with an empty value", () => {
|
|
||||||
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
|
||||||
enabled: true,
|
|
||||||
name: "foo:bar",
|
|
||||||
value: "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves malformed lines instead of dropping their contents", () => {
|
|
||||||
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
|
||||||
enabled: true,
|
|
||||||
name: "not a pair",
|
|
||||||
value: "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unescapes newlines in parsed values", () => {
|
|
||||||
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
|
||||||
enabled: true,
|
|
||||||
name: "foo",
|
|
||||||
value: "bar\nbaz",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type ButtonProps = BaseButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
|
||||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { MouseEvent } from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import type { ButtonProps } from "./Button";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
type DismissibleBannerSize = "sm" | "xs";
|
|
||||||
|
|
||||||
export function DismissibleBanner({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
id,
|
|
||||||
size = "sm",
|
|
||||||
onDismiss,
|
|
||||||
onShow,
|
|
||||||
actions,
|
|
||||||
...props
|
|
||||||
}: BannerProps & {
|
|
||||||
id: string;
|
|
||||||
size?: DismissibleBannerSize;
|
|
||||||
onDismiss?: () => void | Promise<void>;
|
|
||||||
onShow?: () => void | Promise<void>;
|
|
||||||
actions?: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
color?: Color;
|
|
||||||
variant?: ButtonProps["variant"];
|
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
set: setDismissed,
|
|
||||||
value: dismissed,
|
|
||||||
} = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
|
||||||
key: ["dismiss-banner", id],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldShow = !isLoading && !dismissed;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldShow) {
|
|
||||||
Promise.resolve(onShow?.()).catch(console.error);
|
|
||||||
}
|
|
||||||
}, [onShow, shouldShow]);
|
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
|
||||||
|
|
||||||
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
|
|
||||||
const stopParentClick = (event: MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Banner
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"relative",
|
|
||||||
size === "xs" && "!px-2 !py-2 text-xs",
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="@container">
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
|
||||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color={props.color}
|
|
||||||
size={actionSize}
|
|
||||||
onClick={(event) => {
|
|
||||||
stopParentClick(event);
|
|
||||||
setDismissed(true).catch(console.error);
|
|
||||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
|
||||||
}}
|
|
||||||
title="Dismiss message"
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
{actions?.map((a) => (
|
|
||||||
<Button
|
|
||||||
key={a.label}
|
|
||||||
variant={a.variant ?? "border"}
|
|
||||||
color={a.color ?? props.color}
|
|
||||||
size={actionSize}
|
|
||||||
onClick={(event) => {
|
|
||||||
stopParentClick(event);
|
|
||||||
a.onClick();
|
|
||||||
}}
|
|
||||||
title={a.label}
|
|
||||||
>
|
|
||||||
{a.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parser } from "./filter";
|
|
||||||
|
|
||||||
function getNodeNames(input: string): string[] {
|
|
||||||
const tree = parser.parse(input);
|
|
||||||
const nodes: string[] = [];
|
|
||||||
const cursor = tree.cursor();
|
|
||||||
do {
|
|
||||||
if (cursor.name !== "Query") {
|
|
||||||
nodes.push(cursor.name);
|
|
||||||
}
|
|
||||||
} while (cursor.next());
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("filter grammar", () => {
|
|
||||||
test("parses URL-like field values as one value", () => {
|
|
||||||
const nodes = getNodeNames("@url:yaak.app/foo-bar");
|
|
||||||
|
|
||||||
expect(nodes).not.toContain("⚠");
|
|
||||||
expect(nodes).toContain("FieldValue");
|
|
||||||
expect(nodes).toContain("FieldValueWord");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses punctuation-heavy field values as one value", () => {
|
|
||||||
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
|
|
||||||
|
|
||||||
expect(nodes).not.toContain("⚠");
|
|
||||||
expect(nodes).toContain("FieldValue");
|
|
||||||
expect(nodes).toContain("FieldValueWord");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses operator-looking field values as one value", () => {
|
|
||||||
const negativeValueNodes = getNodeNames("@url:-foo");
|
|
||||||
const operatorWordNodes = getNodeNames("@url:AND");
|
|
||||||
|
|
||||||
expect(negativeValueNodes).not.toContain("⚠");
|
|
||||||
expect(negativeValueNodes).toContain("FieldValueWord");
|
|
||||||
expect(operatorWordNodes).not.toContain("⚠");
|
|
||||||
expect(operatorWordNodes).toContain("FieldValueWord");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
|
||||||
import {LRParser} from "@lezer/lr"
|
|
||||||
import {highlight} from "./highlight"
|
|
||||||
export const parser = LRParser.deserialize({
|
|
||||||
version: 14,
|
|
||||||
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
|
|
||||||
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
|
|
||||||
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
|
|
||||||
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
|
|
||||||
maxTerm: 27,
|
|
||||||
nodeProps: [
|
|
||||||
["openedBy", 8,"LParen"],
|
|
||||||
["closedBy", 9,"RParen"]
|
|
||||||
],
|
|
||||||
propSources: [highlight],
|
|
||||||
skippedNodes: [0,22],
|
|
||||||
repeatNodeCount: 3,
|
|
||||||
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
|
|
||||||
tokenizers: [0, 1],
|
|
||||||
topRules: {"Query":[0,1]},
|
|
||||||
tokenPrec: 145
|
|
||||||
})
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { formatFieldFilter } from "./format";
|
|
||||||
import { evaluate, parseQuery } from "./query";
|
|
||||||
|
|
||||||
function matchesFormattedUrl(value: string) {
|
|
||||||
return evaluate(parseQuery(formatFieldFilter("url", value)), {
|
|
||||||
fields: { url: value },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("formatFieldFilter", () => {
|
|
||||||
test("keeps URL-like values bare", () => {
|
|
||||||
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
|
|
||||||
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps non-syntax punctuation bare", () => {
|
|
||||||
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
|
|
||||||
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps values that start with an operator token bare", () => {
|
|
||||||
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
|
|
||||||
expect(matchesFormattedUrl("-foo")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps boolean operator words bare", () => {
|
|
||||||
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
|
|
||||||
expect(formatFieldFilter("url", "or")).toBe("@url:or");
|
|
||||||
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
|
|
||||||
expect(matchesFormattedUrl("AND")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("escapes quoted values", () => {
|
|
||||||
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
|
|
||||||
expect(matchesFormattedUrl('say "hi"')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quotes values that start with a quote", () => {
|
|
||||||
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
|
|
||||||
expect(matchesFormattedUrl('"hi"')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const bareFieldValue = /^[^\s"]\S*$/;
|
|
||||||
|
|
||||||
export function formatFieldFilter(field: string, value: string) {
|
|
||||||
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
||||||
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
|
|
||||||
return `@${field}:${filterValue}`;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Diagnostic } from "@codemirror/lint";
|
|
||||||
import type { EditorView } from "@codemirror/view";
|
|
||||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
|
||||||
|
|
||||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
|
||||||
|
|
||||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
|
||||||
const MESSAGES: Record<string, string> = {
|
|
||||||
InvalidSymbol: "Invalid symbol",
|
|
||||||
InvalidNumberFormat: "Invalid number format",
|
|
||||||
PropertyNameExpected: "Property name expected",
|
|
||||||
ValueExpected: "Value expected",
|
|
||||||
ColonExpected: "Colon expected",
|
|
||||||
CommaExpected: "Comma expected",
|
|
||||||
CloseBraceExpected: "Closing brace expected",
|
|
||||||
CloseBracketExpected: "Closing bracket expected",
|
|
||||||
EndOfFileExpected: "End of file expected",
|
|
||||||
InvalidCommentToken: "Comments are not allowed",
|
|
||||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
|
||||||
UnexpectedEndOfString: "Unexpected end of string",
|
|
||||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
|
||||||
InvalidUnicode: "Invalid unicode sequence",
|
|
||||||
InvalidEscapeCharacter: "Invalid escape character",
|
|
||||||
InvalidCharacter: "Invalid character",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface JsonLintOptions {
|
|
||||||
allowComments?: boolean;
|
|
||||||
allowTrailingCommas?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
|
||||||
return (view: EditorView): Diagnostic[] => {
|
|
||||||
const doc = view.state.doc.toString();
|
|
||||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
|
||||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
|
||||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
|
||||||
|
|
||||||
const errors: ParseError[] = [];
|
|
||||||
parse(escapedDoc, errors, {
|
|
||||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
|
||||||
disallowComments: !(options?.allowComments ?? true),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
|
||||||
const error = errors[0];
|
|
||||||
if (error == null) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
from: error.offset,
|
|
||||||
to: error.offset + error.length,
|
|
||||||
severity: "error",
|
|
||||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
import { EditorState } from "@codemirror/state";
|
|
||||||
import { jsonc } from "@shopify/lang-jsonc";
|
|
||||||
import { text } from "./text/extension";
|
|
||||||
import { twig } from "./twig/extension";
|
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import {
|
|
||||||
COLLAPSE_MEDIA_CHARS,
|
|
||||||
COLLAPSE_TOKEN_CHARS,
|
|
||||||
collapseDecorations,
|
|
||||||
largeValues,
|
|
||||||
MAX_VISIBLE_LINE_CHARS,
|
|
||||||
} from "./largeValues";
|
|
||||||
import type { SniffedValue } from "./sniffValue";
|
|
||||||
|
|
||||||
const BIG = "A".repeat(1_000_000);
|
|
||||||
|
|
||||||
/** With a grammar, so tokens can be collapsed individually */
|
|
||||||
const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
|
|
||||||
|
|
||||||
/** Without a grammar, so only the column rule applies */
|
|
||||||
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The decorations as if the whole document were on screen.
|
|
||||||
*
|
|
||||||
* In the editor the plugin passes the viewport instead, which is the same call with narrower
|
|
||||||
* ranges — the rules themselves don't know the difference.
|
|
||||||
*/
|
|
||||||
function decorationsFor(state: EditorState) {
|
|
||||||
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapsedRanges(state: EditorState) {
|
|
||||||
const ranges: { from: number; to: number }[] = [];
|
|
||||||
const iter = decorationsFor(state).iter();
|
|
||||||
while (iter.value != null) {
|
|
||||||
ranges.push({ from: iter.from, to: iter.to });
|
|
||||||
iter.next();
|
|
||||||
}
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How much of each line is still rendered */
|
|
||||||
function visibleLineLengths(state: EditorState) {
|
|
||||||
const hidden = collapsedRanges(state);
|
|
||||||
const lengths: number[] = [];
|
|
||||||
for (let n = 1; n <= state.doc.lines; n++) {
|
|
||||||
const line = state.doc.line(n);
|
|
||||||
const covered = hidden
|
|
||||||
.filter((h) => h.from >= line.from && h.to <= line.to)
|
|
||||||
.reduce((sum, h) => sum + (h.to - h.from), 0);
|
|
||||||
lengths.push(line.length - covered);
|
|
||||||
}
|
|
||||||
return lengths;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("collapsing", () => {
|
|
||||||
test("leaves an ordinary body alone", () => {
|
|
||||||
expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves a large body of short lines alone", () => {
|
|
||||||
const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
|
|
||||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves a line just under the column limit alone", () => {
|
|
||||||
expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never renders more than the column limit per line", () => {
|
|
||||||
for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
|
|
||||||
for (const length of visibleLineLengths(state)) {
|
|
||||||
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the document text intact", () => {
|
|
||||||
const doc = `{"image":"${BIG}"}`;
|
|
||||||
expect(jsonState(doc).sliceDoc()).toBe(doc);
|
|
||||||
expect(jsonState(doc).doc.length).toBe(doc.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("token collapsing, with a grammar", () => {
|
|
||||||
test("hides the whole value, leaving its quotes visible", () => {
|
|
||||||
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
|
|
||||||
const state = jsonState(doc);
|
|
||||||
const ranges = collapsedRanges(state);
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
|
|
||||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
|
||||||
expect(doc[ranges[0]!.from - 1]).toBe('"');
|
|
||||||
expect(doc[ranges[0]!.to]).toBe('"');
|
|
||||||
|
|
||||||
// Everything after the value is still rendered, unlike a plain column cut
|
|
||||||
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
|
|
||||||
// machine. With a bigger body this falls back to the column cut, which is by design but
|
|
||||||
// makes the assertion depend on how fast the runner is.
|
|
||||||
test("keeps every key visible in a minified body with several large values", () => {
|
|
||||||
const chunk = "B".repeat(20_000);
|
|
||||||
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
|
|
||||||
const state = jsonState(doc);
|
|
||||||
const ranges = collapsedRanges(state);
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(5);
|
|
||||||
for (const key of ["a", "b", "c", "d", "e"]) {
|
|
||||||
// No collapse swallows the key
|
|
||||||
const at = doc.indexOf(`"${key}":`);
|
|
||||||
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
|
|
||||||
}
|
|
||||||
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("collapses a value on a pretty-printed line", () => {
|
|
||||||
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
|
|
||||||
const state = jsonState(doc);
|
|
||||||
const ranges = collapsedRanges(state);
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
|
||||||
// Only the long line is touched, and all that is left of it is ` "image": ""`
|
|
||||||
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
|
|
||||||
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores tokens under the collapse threshold", () => {
|
|
||||||
// Under the threshold once the surrounding quotes are counted
|
|
||||||
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
|
|
||||||
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
|
|
||||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
|
|
||||||
// Nothing is big enough to collapse on its own, so the column rule takes over
|
|
||||||
const ranges = collapsedRanges(jsonState(doc));
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(ranges[0]!.to).toBe(doc.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("column collapsing, without a grammar", () => {
|
|
||||||
test("collapses everything past the limit", () => {
|
|
||||||
const ranges = collapsedRanges(plainState(BIG));
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(ranges[0]!.to).toBe(BIG.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles a long line of many short tokens", () => {
|
|
||||||
// A single-line CSV row: no token is long enough to collapse on its own
|
|
||||||
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
|
|
||||||
const ranges = collapsedRanges(plainState(row));
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("collapses each long line independently", () => {
|
|
||||||
const doc = `${BIG}\nshort\n${BIG}`;
|
|
||||||
const ranges = collapsedRanges(plainState(doc));
|
|
||||||
expect(ranges).toHaveLength(2);
|
|
||||||
for (const length of visibleLineLengths(plainState(doc))) {
|
|
||||||
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never hides a line break", () => {
|
|
||||||
const doc = `${BIG}\nshort`;
|
|
||||||
const state = plainState(doc);
|
|
||||||
for (const { from, to } of collapsedRanges(state)) {
|
|
||||||
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
|
||||||
}
|
|
||||||
expect(state.doc.lines).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("undelimited tokens", () => {
|
|
||||||
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
|
|
||||||
// line with nothing on it but a tag, so the column cut handles it instead.
|
|
||||||
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
|
|
||||||
|
|
||||||
test("falls back to the column cut for a long line of plain text", () => {
|
|
||||||
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
|
|
||||||
const ranges = collapsedRanges(textState(doc));
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(ranges[0]!.to).toBe(doc.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves the start of the line readable", () => {
|
|
||||||
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
|
|
||||||
const state = textState(doc);
|
|
||||||
const ranges = collapsedRanges(state);
|
|
||||||
|
|
||||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sniffing the collapsed value", () => {
|
|
||||||
/** The widget standing in for each collapse, in document order */
|
|
||||||
function widgets(state: EditorState) {
|
|
||||||
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
|
|
||||||
const iter = decorationsFor(state).iter();
|
|
||||||
while (iter.value != null) {
|
|
||||||
found.push(iter.value.spec.widget);
|
|
||||||
iter.next();
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A base64 value that starts with a PNG signature and runs on well past the limits */
|
|
||||||
function pngBase64(length = 1_000_000) {
|
|
||||||
const bytes = new Uint8Array(length);
|
|
||||||
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("names the format of a base64 value inside JSON", () => {
|
|
||||||
const state = jsonState(`{"image":"${pngBase64()}"}`);
|
|
||||||
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("names the format of a data URI", () => {
|
|
||||||
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
|
|
||||||
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("names nothing when the value is just text", () => {
|
|
||||||
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
|
|
||||||
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reads a column cut from the start of its line, not from the cut", () => {
|
|
||||||
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
|
|
||||||
// belongs to begins at the start of the line, which is where the signature is
|
|
||||||
const state = plainState(pngBase64());
|
|
||||||
const [widget] = widgets(state);
|
|
||||||
|
|
||||||
expect(widget?.valueFrom).toBe(0);
|
|
||||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reads a token collapse from the value itself", () => {
|
|
||||||
const doc = `{"image":"${pngBase64()}"}`;
|
|
||||||
const [widget] = widgets(jsonState(doc));
|
|
||||||
|
|
||||||
// Just past the opening quote, so the signature is the first thing it sees
|
|
||||||
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
|
|
||||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("finds a run that starts partway through a line, and names it", () => {
|
|
||||||
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
|
|
||||||
// so the prefix stays on screen and the blob is still named.
|
|
||||||
const prefix = "some prefix text ";
|
|
||||||
const state = plainState(`${prefix}${pngBase64()}`);
|
|
||||||
const [widget] = widgets(state);
|
|
||||||
|
|
||||||
expect(widget?.valueFrom).toBe(prefix.length);
|
|
||||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("recomputing", () => {
|
|
||||||
test("updates when the document changes", () => {
|
|
||||||
const state = plainState('{"image":"short"}');
|
|
||||||
expect(collapsedRanges(state)).toEqual([]);
|
|
||||||
|
|
||||||
const next = state.update({
|
|
||||||
changes: { from: 0, to: state.doc.length, insert: BIG },
|
|
||||||
}).state;
|
|
||||||
expect(collapsedRanges(next)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("media collapsing, below the length rules", () => {
|
|
||||||
/** A real PNG signature, at a size that no length rule would touch */
|
|
||||||
function png(bytes: number) {
|
|
||||||
const b = new Uint8Array(bytes);
|
|
||||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of b) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Well under every length threshold, so only the media rule can collapse it */
|
|
||||||
const SMALL = png(3_000);
|
|
||||||
|
|
||||||
test("collapses a small image on a short line", () => {
|
|
||||||
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
|
|
||||||
|
|
||||||
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
|
|
||||||
const ranges = collapsedRanges(state);
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("names it, so the tag can say what it is", () => {
|
|
||||||
const state = jsonState(`{"avatar":"${SMALL}"}`);
|
|
||||||
const iter = decorationsFor(state).iter();
|
|
||||||
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves a value of the same size alone when nothing recognises it", () => {
|
|
||||||
// The only difference from the case above is what the bytes turn out to be
|
|
||||||
const prose = "x".repeat(SMALL.length);
|
|
||||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves anything under the media threshold alone, recognised or not", () => {
|
|
||||||
const tiny = png(400);
|
|
||||||
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
|
|
||||||
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves text alone even when its first bytes match a signature", () => {
|
|
||||||
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
|
|
||||||
// enough that the sniff alone must not be allowed to hide something.
|
|
||||||
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
|
|
||||||
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
|
|
||||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("still collapses it once it is long enough to be a rendering problem", () => {
|
|
||||||
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
|
|
||||||
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
|
|
||||||
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("collapses a data URI on a short line", () => {
|
|
||||||
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
|
|
||||||
expect(collapsedRanges(state)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("still leaves an ordinary document completely untouched", () => {
|
|
||||||
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
|
|
||||||
"\n",
|
|
||||||
);
|
|
||||||
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("media rule only, for a document being edited", () => {
|
|
||||||
/** What an editable editor gets: rule 2 alone */
|
|
||||||
function editableRanges(state: EditorState) {
|
|
||||||
const ranges: { from: number; to: number }[] = [];
|
|
||||||
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
|
||||||
while (iter.value != null) {
|
|
||||||
ranges.push({ from: iter.from, to: iter.to });
|
|
||||||
iter.next();
|
|
||||||
}
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
function png(bytes: number) {
|
|
||||||
const b = new Uint8Array(bytes);
|
|
||||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of b) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("collapses a pasted image, the one thing it is for", () => {
|
|
||||||
const value = png(3_000);
|
|
||||||
const state = jsonState(`{\n "avatar": "${value}"\n}`);
|
|
||||||
|
|
||||||
expect(editableRanges(state)).toHaveLength(1);
|
|
||||||
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves a long value alone when nothing can name it", () => {
|
|
||||||
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
|
|
||||||
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
|
|
||||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(editableRanges(jsonState(doc))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never cuts at the column limit, which would strand text out of reach", () => {
|
|
||||||
// Rule 3 territory: a minified body with no value big enough to collapse on its own
|
|
||||||
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
|
|
||||||
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
|
||||||
expect(editableRanges(jsonState(doc))).toEqual([]);
|
|
||||||
// The read-only rules still cut it
|
|
||||||
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never swallows a template tag, collapsing only the run beside it", () => {
|
|
||||||
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
|
|
||||||
// on screen where it can still be read and edited
|
|
||||||
const tag = "{{ image }}";
|
|
||||||
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
|
|
||||||
const ranges = editableRanges(jsonState(doc));
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
|
|
||||||
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("finds a data URI whole, header and all", () => {
|
|
||||||
const value = `data:image/jpeg;base64,${png(3_000)}`;
|
|
||||||
const doc = `{"avatar":"${value}"}`;
|
|
||||||
const ranges = editableRanges(jsonState(doc));
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides no line break, so the plugin may provide it from the viewport", () => {
|
|
||||||
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
|
|
||||||
for (const { from, to } of editableRanges(state)) {
|
|
||||||
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("templated fields, where the grammar is an overlay", () => {
|
|
||||||
// Every editable field mixes its language with twig, which mounts the base language as an
|
|
||||||
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
|
|
||||||
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
|
|
||||||
// case that has to keep working: an image pasted into a JSON request body.
|
|
||||||
const twigState = (doc: string) =>
|
|
||||||
EditorState.create({
|
|
||||||
doc,
|
|
||||||
extensions: [
|
|
||||||
twig({
|
|
||||||
base: jsonc(),
|
|
||||||
environmentVariables: [],
|
|
||||||
completionOptions: [],
|
|
||||||
onClickVariable: () => {},
|
|
||||||
onClickMissingVariable: () => {},
|
|
||||||
onClickPathParameter: () => {},
|
|
||||||
extraExtensions: [],
|
|
||||||
}),
|
|
||||||
largeValues,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
function png(bytes: number) {
|
|
||||||
const b = new Uint8Array(bytes);
|
|
||||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of b) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("collapses a pasted image in a templated body", () => {
|
|
||||||
const value = png(3_000);
|
|
||||||
const state = twigState(`{\n "avatar": "${value}"\n}`);
|
|
||||||
const ranges: { from: number; to: number }[] = [];
|
|
||||||
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
|
||||||
while (iter.value != null) {
|
|
||||||
ranges.push({ from: iter.from, to: iter.to });
|
|
||||||
iter.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ranges).toHaveLength(1);
|
|
||||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("viewport scoping", () => {
|
|
||||||
const doc = () => {
|
|
||||||
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
|
||||||
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
test("decorates only the ranges it is given", () => {
|
|
||||||
const state = jsonState(doc());
|
|
||||||
const secondLine = state.doc.line(3);
|
|
||||||
|
|
||||||
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
|
||||||
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
|
|
||||||
|
|
||||||
expect(all.size).toBe(3);
|
|
||||||
expect(one.size).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("covers a line the range only partly overlaps", () => {
|
|
||||||
// The viewport can start mid-line; the collapse still has to span the whole value
|
|
||||||
const state = jsonState(doc());
|
|
||||||
const line = state.doc.line(2);
|
|
||||||
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
|
|
||||||
|
|
||||||
expect(partial.size).toBe(1);
|
|
||||||
const iter = partial.iter();
|
|
||||||
expect(iter.to).toBeGreaterThan(line.from + 6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
|
||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
|
||||||
import type { EditorState, Extension, Range } from "@codemirror/state";
|
|
||||||
import type { Tree as SyntaxTree } from "@lezer/common";
|
|
||||||
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
|
|
||||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
|
||||||
import { fireAndForget } from "../../../lib/fireAndForget";
|
|
||||||
import type { SniffedValue } from "./sniffValue";
|
|
||||||
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of a line may be rendered before the rest is collapsed.
|
|
||||||
*
|
|
||||||
* VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
|
|
||||||
* reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
|
|
||||||
* collapse to a placeholder that can be opened instead.
|
|
||||||
*/
|
|
||||||
export const MAX_VISIBLE_LINE_CHARS = 10_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
|
|
||||||
* enough that nothing a person might actually read is ever hidden on length alone.
|
|
||||||
*/
|
|
||||||
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
|
|
||||||
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
|
|
||||||
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
|
|
||||||
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
|
|
||||||
* can't identify stays visible until it's big enough to be a problem on its own.
|
|
||||||
*/
|
|
||||||
export const COLLAPSE_MEDIA_CHARS = 1_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
|
|
||||||
* is only noise on screen.
|
|
||||||
*
|
|
||||||
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
|
|
||||||
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
|
|
||||||
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
|
|
||||||
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
|
|
||||||
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
|
|
||||||
*
|
|
||||||
* Three rules:
|
|
||||||
*
|
|
||||||
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
|
||||||
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
|
|
||||||
* values keeps every one of its keys. Needs a grammar to find the value.
|
|
||||||
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
|
|
||||||
* which is what catches an ordinary embedded image long before it is a rendering problem.
|
|
||||||
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
|
|
||||||
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
|
|
||||||
* line that is simply long.
|
|
||||||
*
|
|
||||||
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
|
|
||||||
* looked at, so a document of ordinary short lines costs a length check per visible line and
|
|
||||||
* nothing else — no grammar is consulted and no tree is walked.
|
|
||||||
*
|
|
||||||
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
|
||||||
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
|
|
||||||
*
|
|
||||||
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
|
|
||||||
* gives its format up in the first dozen bytes — so the tag can name the format and open the
|
|
||||||
* value in the matching viewer without any of it being read. See {@link sniffValue}.
|
|
||||||
*
|
|
||||||
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
|
|
||||||
* by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
|
|
||||||
* which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
|
|
||||||
* So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
|
|
||||||
* they hide text by length alone, without being able to say what it is: under rule 3 a minified
|
|
||||||
* body would become uneditable past the column limit, which really would be editing text you
|
|
||||||
* can't see.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of the three rules to apply.
|
|
||||||
*
|
|
||||||
* `media` is the subset safe for a document being edited: collapse only what we can name.
|
|
||||||
*/
|
|
||||||
export type CollapseRules = "all" | "media";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A hidden range, the value it belongs to, and what that value turned out to be.
|
|
||||||
*
|
|
||||||
* The hidden range and the value differ under the column rule, where the value starts at the
|
|
||||||
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
|
|
||||||
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
|
|
||||||
*/
|
|
||||||
interface Collapse {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
valueFrom: number;
|
|
||||||
sniffed: SniffedValue | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
|
|
||||||
* rendering React here would leave it empty while CodeMirror measures line heights.
|
|
||||||
*/
|
|
||||||
const CHEVRON_DOWN =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
|
||||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
|
||||||
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the tag as the thing that opens the menu.
|
|
||||||
*
|
|
||||||
* The whole tag is the target rather than the chevron alone: there is only one action, and a
|
|
||||||
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
|
|
||||||
* itself. The chevron stays as the affordance that says so.
|
|
||||||
*
|
|
||||||
* A span with a role rather than a real button, because a button's box model makes the line
|
|
||||||
* taller — the one thing this extension exists to keep from happening.
|
|
||||||
*/
|
|
||||||
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
|
|
||||||
el.role = "button";
|
|
||||||
el.ariaHasPopup = "menu";
|
|
||||||
el.tabIndex = 0;
|
|
||||||
el.title = title;
|
|
||||||
el.ariaLabel = title;
|
|
||||||
|
|
||||||
// Keep the editor from moving the cursor when the tag is pressed
|
|
||||||
el.addEventListener("mousedown", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
el.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpen();
|
|
||||||
});
|
|
||||||
el.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpen();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class LargeValueWidget extends WidgetType {
|
|
||||||
constructor(
|
|
||||||
private readonly from: number,
|
|
||||||
private readonly to: number,
|
|
||||||
private readonly valueFrom: number,
|
|
||||||
private readonly sniffed: SniffedValue | null,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: LargeValueWidget) {
|
|
||||||
return (
|
|
||||||
other.from === this.from &&
|
|
||||||
other.to === this.to &&
|
|
||||||
other.valueFrom === this.valueFrom &&
|
|
||||||
other.sniffed?.mime === this.sniffed?.mime
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView) {
|
|
||||||
const length = this.to - this.from;
|
|
||||||
|
|
||||||
const el = document.createElement("span");
|
|
||||||
// The same neutral tag styling a path parameter uses. The theme scope matters: these
|
|
||||||
// tokens resolve against the tag palette, not the editor's ambient one, where a
|
|
||||||
// tag-sized border is meant to be near invisible.
|
|
||||||
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
|
||||||
|
|
||||||
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
|
|
||||||
if (thumbnail != null) {
|
|
||||||
el.appendChild(thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
|
|
||||||
// Without a name there is nothing to show but the fact that something is missing.
|
|
||||||
label.textContent =
|
|
||||||
this.sniffed == null
|
|
||||||
? `${formatSize(length)} hidden…`
|
|
||||||
: `${this.sniffed.label} · ${formatSize(length)}`;
|
|
||||||
el.appendChild(label);
|
|
||||||
|
|
||||||
const chevron = document.createElement("span");
|
|
||||||
chevron.className = "tag-action";
|
|
||||||
chevron.ariaHidden = "true";
|
|
||||||
chevron.innerHTML = CHEVRON_DOWN;
|
|
||||||
el.appendChild(chevron);
|
|
||||||
|
|
||||||
makeTagButton(
|
|
||||||
el,
|
|
||||||
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
|
||||||
() => fireAndForget(this.openMenu(view, el)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy, view and save, in a menu opened against the button.
|
|
||||||
*
|
|
||||||
* Everything the menu needs is pulled in on click. The editor loads on every response, and
|
|
||||||
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
|
|
||||||
* asks for them.
|
|
||||||
*/
|
|
||||||
private async openMenu(view: EditorView, tag: HTMLElement) {
|
|
||||||
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
|
||||||
await Promise.all([
|
|
||||||
import("../../../lib/contextMenu"),
|
|
||||||
import("../../LargeValueDialog"),
|
|
||||||
import("../../../lib/largeValue"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { sniffed } = this;
|
|
||||||
// Sliced when an action runs rather than now, so opening the menu never touches the value
|
|
||||||
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
|
|
||||||
const head = view.state.sliceDoc(
|
|
||||||
this.valueFrom,
|
|
||||||
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The whole tag, so the menu can align to whichever edge has room beside it
|
|
||||||
const rect = tag.getBoundingClientRect();
|
|
||||||
toggleContextMenu({
|
|
||||||
id: "large-value",
|
|
||||||
triggerPosition: { x: rect.left, y: rect.bottom },
|
|
||||||
triggerRect: rect,
|
|
||||||
triggerEl: tag,
|
|
||||||
items: [
|
|
||||||
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
|
||||||
...largeValueActions({
|
|
||||||
value,
|
|
||||||
sniffed,
|
|
||||||
// Exactly what the tag stands in for, which under the column rule is only the tail
|
|
||||||
copyText: () => view.state.sliceDoc(this.from, this.to),
|
|
||||||
onView: () => showLargeValueDialog({ text: value(), sniffed }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
|
|
||||||
*
|
|
||||||
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
|
|
||||||
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
|
|
||||||
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
|
|
||||||
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
|
|
||||||
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
|
|
||||||
* that previews some images and not others is worse than one that previews none. Only widgets in
|
|
||||||
* the viewport are ever built, so a screenful is the most that decode at once.
|
|
||||||
*/
|
|
||||||
const THUMBNAIL_MAX_CHARS = 4_000_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A preview of the value, at a size fixed before it loads.
|
|
||||||
*
|
|
||||||
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
|
|
||||||
* the line's height or the tag's width. A growing line box is the layout cost this whole
|
|
||||||
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
|
|
||||||
*
|
|
||||||
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
|
|
||||||
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
|
|
||||||
* thing that must not happen here.
|
|
||||||
*/
|
|
||||||
function buildThumbnail(
|
|
||||||
view: EditorView,
|
|
||||||
valueFrom: number,
|
|
||||||
to: number,
|
|
||||||
sniffed: SniffedValue | null,
|
|
||||||
): HTMLElement | null {
|
|
||||||
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
|
|
||||||
if (sniffed.encoding !== "base64") return null;
|
|
||||||
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
|
|
||||||
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.className = "tag-thumbnail";
|
|
||||||
img.alt = "";
|
|
||||||
img.ariaHidden = "true";
|
|
||||||
img.decoding = "async";
|
|
||||||
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
|
|
||||||
// broken-image glyph sitting in the tag.
|
|
||||||
img.addEventListener("error", () => img.remove());
|
|
||||||
// Read the document and build the data URI after the widget is measured and on screen
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!img.isConnected) return;
|
|
||||||
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
|
|
||||||
img.src = `data:${sniffed.mime};base64,${payload}`;
|
|
||||||
});
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Line {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The lines the given ranges touch, in document order and each listed once.
|
|
||||||
*
|
|
||||||
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
|
|
||||||
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
|
|
||||||
*/
|
|
||||||
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
|
|
||||||
const lines: Line[] = [];
|
|
||||||
let lastFrom = -1;
|
|
||||||
|
|
||||||
for (const range of ranges) {
|
|
||||||
let pos = range.from;
|
|
||||||
for (;;) {
|
|
||||||
const line = state.doc.lineAt(pos);
|
|
||||||
if (line.from > lastFrom) {
|
|
||||||
lastFrom = line.from;
|
|
||||||
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
|
|
||||||
lines.push({ from: line.from, to: line.to });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line.to >= range.to) break;
|
|
||||||
pos = line.to + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long to spend parsing before falling back to the column rule.
|
|
||||||
*
|
|
||||||
* The initial parse is budgeted by time, so it stops partway through a document with several
|
|
||||||
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
|
|
||||||
* 12 ms, against the 171 ms of layout it saves.
|
|
||||||
*/
|
|
||||||
const PARSE_TIMEOUT_MS = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The parsed tree covering these lines.
|
|
||||||
*
|
|
||||||
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
|
|
||||||
* inside it is what saves the frame. For everything else we take whatever the background parse
|
|
||||||
* has reached and decorate again when it advances — a forced parse runs from the top of the
|
|
||||||
* document, so making one happen on every scroll would cost far more than the tags are worth.
|
|
||||||
*/
|
|
||||||
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
|
|
||||||
const last = lines[lines.length - 1];
|
|
||||||
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
|
|
||||||
if (last == null || !stalls) {
|
|
||||||
return syntaxTree(state);
|
|
||||||
}
|
|
||||||
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUOTES = ['"', "'", "`"];
|
|
||||||
|
|
||||||
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
|
|
||||||
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
|
|
||||||
|
|
||||||
/** The header that turns a bare run into a data URI, when one sits right before it. */
|
|
||||||
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encoded media on this line, found by reading the text rather than the grammar.
|
|
||||||
*
|
|
||||||
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
|
|
||||||
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
|
|
||||||
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
|
|
||||||
* inside it. Rule 2 has to work in those fields above all, since that is where images get
|
|
||||||
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
|
|
||||||
* the alphabet does.
|
|
||||||
*
|
|
||||||
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
|
|
||||||
* as one thing and its declared type is what names it.
|
|
||||||
*/
|
|
||||||
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
|
|
||||||
const text = state.sliceDoc(line.from, line.to);
|
|
||||||
const collapses: Collapse[] = [];
|
|
||||||
|
|
||||||
ENCODED_RUN.lastIndex = 0;
|
|
||||||
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
|
|
||||||
let start = m.index;
|
|
||||||
// A data URI's own header breaks the alphabet, so the run starts after it
|
|
||||||
const header = DATA_URI_HEAD.exec(text.slice(0, start));
|
|
||||||
if (header != null) {
|
|
||||||
start = header.index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueFrom = line.from + start;
|
|
||||||
const valueTo = line.from + m.index + m[0].length;
|
|
||||||
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
|
||||||
const sniffed = sniffValue(head);
|
|
||||||
|
|
||||||
// Only hide what we can name. A short magic number matches by chance now and then, so the
|
|
||||||
// run itself has to look encoded too.
|
|
||||||
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
|
|
||||||
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return collapses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
|
|
||||||
*
|
|
||||||
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
|
|
||||||
* nobody is editing — which is also the only place this rule applies.
|
|
||||||
*/
|
|
||||||
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
|
||||||
const collapses: Collapse[] = [];
|
|
||||||
|
|
||||||
tree.iterate({
|
|
||||||
from: line.from,
|
|
||||||
to: line.to,
|
|
||||||
enter: (node) => {
|
|
||||||
// A node this small can't contain anything worth collapsing, and neither can its children
|
|
||||||
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
|
|
||||||
// Only leaves, so we collapse the string itself rather than the object holding it
|
|
||||||
if (node.node.firstChild != null) return true;
|
|
||||||
|
|
||||||
const from = Math.max(node.from, line.from);
|
|
||||||
const to = Math.min(node.to, line.to);
|
|
||||||
const open = state.sliceDoc(from, from + 1);
|
|
||||||
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
|
||||||
if (!quoted) return false;
|
|
||||||
|
|
||||||
// Everything inside the quotes. The whole value is hidden, so it is its own value range
|
|
||||||
const valueFrom = from + 1;
|
|
||||||
const valueTo = to - 1;
|
|
||||||
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
|
|
||||||
|
|
||||||
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
|
||||||
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return collapses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both rules' findings as one list, in document order, with nothing overlapping.
|
|
||||||
*
|
|
||||||
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
|
|
||||||
* error. The token wins where they collide, because its range stops at the quotes and so leaves
|
|
||||||
* the structure around it readable.
|
|
||||||
*/
|
|
||||||
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
|
|
||||||
const merged = [...tokens];
|
|
||||||
for (const run of runs) {
|
|
||||||
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
|
|
||||||
merged.push(run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged.sort((a, b) => a.from - b.from);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the line runs past the column limit, counting only what is still visible after the
|
|
||||||
* token collapses, or -1 if it fits.
|
|
||||||
*/
|
|
||||||
function findColumnCut(line: Line, tokens: Collapse[]): number {
|
|
||||||
let visible = 0;
|
|
||||||
let pos = line.from;
|
|
||||||
|
|
||||||
for (const token of [...tokens, null]) {
|
|
||||||
const segmentEnd = token == null ? line.to : token.from;
|
|
||||||
if (segmentEnd > pos) {
|
|
||||||
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
|
|
||||||
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
|
|
||||||
}
|
|
||||||
visible += segmentEnd - pos;
|
|
||||||
}
|
|
||||||
if (token != null) {
|
|
||||||
pos = token.to;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapsesForLine(
|
|
||||||
state: EditorState,
|
|
||||||
tree: SyntaxTree,
|
|
||||||
line: Line,
|
|
||||||
rules: CollapseRules,
|
|
||||||
): Collapse[] {
|
|
||||||
const runs = findMediaRuns(state, line);
|
|
||||||
// Length alone only hides things on a line that would stall without it, and never in a
|
|
||||||
// document being edited, where hiding text by size would put it out of reach
|
|
||||||
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
|
|
||||||
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
|
|
||||||
|
|
||||||
// Short enough to render, so there is nothing left to cut
|
|
||||||
if (!stalls) {
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cut = findColumnCut(line, tokens);
|
|
||||||
if (cut < 0) {
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cut always lands in a visible stretch, so it never splits a token collapse
|
|
||||||
const kept = tokens.filter((t) => t.to <= cut);
|
|
||||||
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
|
|
||||||
// body that is nothing but one base64 blob is still recognisable from there
|
|
||||||
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
|
|
||||||
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
|
|
||||||
return kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The collapses covering the given document ranges.
|
|
||||||
*
|
|
||||||
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
|
|
||||||
* view and no DOM.
|
|
||||||
*/
|
|
||||||
export function collapseDecorations(
|
|
||||||
state: EditorState,
|
|
||||||
ranges: readonly { from: number; to: number }[],
|
|
||||||
rules: CollapseRules = "all",
|
|
||||||
): DecorationSet {
|
|
||||||
const lines = collapsibleLines(state, ranges);
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return Decoration.none;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = treeForLines(state, lines);
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
|
|
||||||
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
|
|
||||||
decorations.push(Decoration.replace({ widget }).range(from, to));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Decoration.set(decorations);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scoped to the viewport, so the cost is a screenful however big the document is.
|
|
||||||
*
|
|
||||||
* Recomputed when the document changes, when the viewport moves, and when background parsing
|
|
||||||
* advances — a value can only be found once the grammar has reached it.
|
|
||||||
*/
|
|
||||||
function collapsePlugin(rules: CollapseRules) {
|
|
||||||
return ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (
|
|
||||||
update.docChanged ||
|
|
||||||
update.viewportChanged ||
|
|
||||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
|
||||||
) {
|
|
||||||
this.decorations = collapseDecorations(
|
|
||||||
update.view.state,
|
|
||||||
update.view.visibleRanges,
|
|
||||||
rules,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations,
|
|
||||||
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
|
|
||||||
provide: (plugin) =>
|
|
||||||
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All three rules. For a document nobody is editing. */
|
|
||||||
export const largeValues: Extension = [collapsePlugin("all")];
|
|
||||||
|
|
||||||
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
|
|
||||||
export const mediaValues: Extension = [collapsePlugin("media")];
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parser } from "./pairs";
|
|
||||||
|
|
||||||
function getNodeNames(input: string): string[] {
|
|
||||||
const tree = parser.parse(input);
|
|
||||||
const nodes: string[] = [];
|
|
||||||
const cursor = tree.cursor();
|
|
||||||
do {
|
|
||||||
if (cursor.name !== "pairs") {
|
|
||||||
nodes.push(cursor.name);
|
|
||||||
}
|
|
||||||
} while (cursor.next());
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pairs grammar", () => {
|
|
||||||
test("parses colon-space pairs with a value", () => {
|
|
||||||
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not parse colon-without-space as a value", () => {
|
|
||||||
const nodes = getNodeNames("foo:bar\n");
|
|
||||||
|
|
||||||
expect(nodes).not.toContain("Value");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
|
||||||
|
|
||||||
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
|
|
||||||
function base64Of(magic: number[], length = 2_000): string {
|
|
||||||
const bytes = new Uint8Array(length);
|
|
||||||
bytes.set(magic);
|
|
||||||
for (let i = magic.length; i < length; i++) {
|
|
||||||
bytes[i] = i % 251;
|
|
||||||
}
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
}
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One character, one byte. These are all ASCII signatures. */
|
|
||||||
function ascii(s: string): number[] {
|
|
||||||
const bytes: number[] = [];
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
bytes.push(s.charCodeAt(i));
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
|
|
||||||
const IGNORED = [0x00, 0x00, 0x00, 0x20];
|
|
||||||
|
|
||||||
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
|
|
||||||
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
|
||||||
const PDF = ascii("%PDF-1.7");
|
|
||||||
|
|
||||||
describe("magic numbers", () => {
|
|
||||||
const cases: [string, number[], string, string][] = [
|
|
||||||
["PNG", PNG, "image/png", "PNG"],
|
|
||||||
["JPEG", JPEG, "image/jpeg", "JPEG"],
|
|
||||||
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
|
|
||||||
["PDF", PDF, "application/pdf", "PDF"],
|
|
||||||
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
|
|
||||||
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
|
|
||||||
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
|
|
||||||
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
|
|
||||||
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
|
|
||||||
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
|
|
||||||
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
|
|
||||||
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
|
|
||||||
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
|
|
||||||
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
|
|
||||||
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
|
|
||||||
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
|
|
||||||
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
|
|
||||||
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
|
|
||||||
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [name, magic, mime, label] of cases) {
|
|
||||||
test(`recognises ${name}`, () => {
|
|
||||||
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("recognises nothing in arbitrary base64", () => {
|
|
||||||
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recognises nothing in text that isn't base64", () => {
|
|
||||||
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recognises nothing in a JWT-shaped value", () => {
|
|
||||||
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reads the url-safe alphabet", () => {
|
|
||||||
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
|
|
||||||
expect(sniffValue(urlSafe)?.label).toBe("PNG");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores leading whitespace, and counts it in the offset", () => {
|
|
||||||
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("data URIs", () => {
|
|
||||||
test("takes the declared type over the bytes", () => {
|
|
||||||
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
|
|
||||||
expect(sniffValue(uri)).toEqual({
|
|
||||||
mime: "image/svg+xml",
|
|
||||||
label: "SVG",
|
|
||||||
offset: "data:image/svg+xml;base64,".length,
|
|
||||||
encoding: "base64",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("points past the header, so the payload can be decoded from there", () => {
|
|
||||||
const payload = base64Of(PNG);
|
|
||||||
const uri = `data:image/png;base64,${payload}`;
|
|
||||||
const sniffed = sniffValue(uri)!;
|
|
||||||
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to the bytes when the declared type says nothing", () => {
|
|
||||||
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
|
|
||||||
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles a header with no type at all", () => {
|
|
||||||
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles extra parameters", () => {
|
|
||||||
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
|
|
||||||
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles a percent-encoded payload", () => {
|
|
||||||
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
|
|
||||||
mime: "text/html",
|
|
||||||
label: "HTML",
|
|
||||||
offset: "data:text/html,".length,
|
|
||||||
encoding: "percent",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaults a bare percent-encoded payload to text", () => {
|
|
||||||
expect(sniffValue("data:,hello%20there")).toMatchObject({
|
|
||||||
mime: "text/plain",
|
|
||||||
encoding: "percent",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decoding only the head", () => {
|
|
||||||
test("reads no more of the value than the bytes asked for", () => {
|
|
||||||
const big = base64Of(PNG, 3_000_000);
|
|
||||||
// Truncating to the head must not change what is found, which is the whole point:
|
|
||||||
// callers only ever hand it a slice
|
|
||||||
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decodes whole 4-character groups only", () => {
|
|
||||||
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
|
|
||||||
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
|
|
||||||
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("declines a value too short to hold a group", () => {
|
|
||||||
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("declines text outside the alphabet", () => {
|
|
||||||
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("labels", () => {
|
|
||||||
test.each([
|
|
||||||
["image/png", "PNG"],
|
|
||||||
["image/svg+xml", "SVG"],
|
|
||||||
["text/plain", "TEXT"],
|
|
||||||
["application/vnd.ms-excel", "MS-EXCEL"],
|
|
||||||
["image/x-icon", "ICON"],
|
|
||||||
["application/octet-stream", "APPLICATION"],
|
|
||||||
["font/woff2", "WOFF2"],
|
|
||||||
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
|
|
||||||
])("%s reads as %s", (mime, label) => {
|
|
||||||
expect(labelForMime(mime)).toBe(label);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
/**
|
|
||||||
* What a collapsed value turns out to be.
|
|
||||||
*
|
|
||||||
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
|
|
||||||
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
|
|
||||||
* point, since the whole reason these values are collapsed is that touching all of one costs
|
|
||||||
* enough to stall the UI.
|
|
||||||
*/
|
|
||||||
export interface SniffedValue {
|
|
||||||
/** Full media type, e.g. `image/png`. What the dialog routes on. */
|
|
||||||
mime: string;
|
|
||||||
/** A word for the tag, e.g. `PNG`. */
|
|
||||||
label: string;
|
|
||||||
/** Where the encoded payload starts in the value, past any `data:` header. */
|
|
||||||
offset: number;
|
|
||||||
encoding: "base64" | "percent";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
|
|
||||||
* length, and far more than the 24 base64 characters the magic numbers are read from.
|
|
||||||
*/
|
|
||||||
export const SNIFF_HEAD_CHARS = 256;
|
|
||||||
|
|
||||||
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
|
|
||||||
const SNIFF_BYTES = 12;
|
|
||||||
|
|
||||||
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
|
|
||||||
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
|
|
||||||
|
|
||||||
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
|
|
||||||
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
|
|
||||||
|
|
||||||
interface Signature {
|
|
||||||
label: string;
|
|
||||||
mime: string;
|
|
||||||
offset?: number;
|
|
||||||
/** Byte values, where `null` matches anything */
|
|
||||||
magic: readonly (number | null)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
|
|
||||||
*
|
|
||||||
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
|
|
||||||
* is one byte, and splitting into code points would be the wrong unit for that.
|
|
||||||
*/
|
|
||||||
function ascii(pattern: string): (number | null)[] {
|
|
||||||
const bytes: (number | null)[] = [];
|
|
||||||
for (let i = 0; i < pattern.length; i++) {
|
|
||||||
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIGNATURES: readonly Signature[] = [
|
|
||||||
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
||||||
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
|
|
||||||
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
|
|
||||||
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
|
|
||||||
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
|
|
||||||
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
|
|
||||||
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
|
|
||||||
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
|
|
||||||
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
|
|
||||||
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
|
|
||||||
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
|
|
||||||
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
|
|
||||||
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
|
|
||||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
|
|
||||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
|
|
||||||
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
|
|
||||||
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
|
|
||||||
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
|
|
||||||
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
|
|
||||||
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
|
|
||||||
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
|
|
||||||
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
|
|
||||||
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
|
|
||||||
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
|
|
||||||
* than in the signature itself. Anything unlisted is some flavour of MP4.
|
|
||||||
*/
|
|
||||||
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
|
|
||||||
avif: { mime: "image/avif", label: "AVIF" },
|
|
||||||
avis: { mime: "image/avif", label: "AVIF" },
|
|
||||||
heic: { mime: "image/heic", label: "HEIC" },
|
|
||||||
heix: { mime: "image/heic", label: "HEIC" },
|
|
||||||
hevc: { mime: "image/heic", label: "HEIC" },
|
|
||||||
mif1: { mime: "image/heif", label: "HEIF" },
|
|
||||||
msf1: { mime: "image/heif", label: "HEIF" },
|
|
||||||
"M4A ": { mime: "audio/mp4", label: "M4A" },
|
|
||||||
"qt ": { mime: "video/quicktime", label: "MOV" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
|
|
||||||
if (bytes.length < offset + magic.length) return false;
|
|
||||||
return magic.every((b, i) => b == null || bytes[offset + i] === b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAscii(bytes: Uint8Array, from: number, length: number): string {
|
|
||||||
return String.fromCharCode(...bytes.subarray(from, from + length));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
|
|
||||||
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
|
|
||||||
if (readAscii(bytes, 4, 4) === "ftyp") {
|
|
||||||
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
|
|
||||||
}
|
|
||||||
const sig = SIGNATURES.find((s) => matches(bytes, s));
|
|
||||||
return sig == null ? null : { mime: sig.mime, label: sig.label };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
|
|
||||||
*
|
|
||||||
* Only the characters those bytes need are decoded. The trailing partial group is dropped
|
|
||||||
* rather than padded, since `atob` rejects a group of one or two characters outright and we
|
|
||||||
* have no use for the byte a three-character group would add.
|
|
||||||
*/
|
|
||||||
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
|
|
||||||
const wanted = Math.ceil(bytes / 3) * 4;
|
|
||||||
let b64 = text.slice(offset, offset + wanted);
|
|
||||||
b64 = b64.slice(0, b64.length - (b64.length % 4));
|
|
||||||
if (b64.length === 0) return null;
|
|
||||||
|
|
||||||
// The URL-safe alphabet stands for the same bytes
|
|
||||||
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
|
|
||||||
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const binary = atob(b64);
|
|
||||||
const out = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
out[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a value is, from its head alone, or null if nothing recognises it.
|
|
||||||
*
|
|
||||||
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
|
|
||||||
* ignored. The offsets in the result are relative to the start of the whole value.
|
|
||||||
*/
|
|
||||||
export function sniffValue(head: string): SniffedValue | null {
|
|
||||||
const lead = head.length - head.trimStart().length;
|
|
||||||
const value = head.slice(lead);
|
|
||||||
|
|
||||||
const dataUri = DATA_URI.exec(value);
|
|
||||||
if (dataUri != null) {
|
|
||||||
const declared = dataUri[1]!.toLowerCase();
|
|
||||||
const offset = lead + dataUri[0].length;
|
|
||||||
if (!dataUri[2]!.split(";").includes("base64")) {
|
|
||||||
// A percent-encoded payload states its own type or is text by definition
|
|
||||||
const mime = declared === "" ? "text/plain" : declared;
|
|
||||||
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
|
|
||||||
}
|
|
||||||
if (!UNINFORMATIVE.includes(declared)) {
|
|
||||||
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
|
|
||||||
}
|
|
||||||
return sniffBase64At(head, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sniffBase64At(head, lead);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sniffBase64At(head: string, offset: number): SniffedValue | null {
|
|
||||||
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
|
|
||||||
const sniffed = bytes == null ? null : sniffBytes(bytes);
|
|
||||||
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
|
|
||||||
const ENCODED_RUN_CHARS = 64;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
|
|
||||||
*
|
|
||||||
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
|
|
||||||
* up about once in every 65,000 values. That was harmless while a value had to be long enough
|
|
||||||
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
|
|
||||||
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
|
|
||||||
* prose leaves the alphabet within a few characters, and an encoded blob never does.
|
|
||||||
*/
|
|
||||||
export function isEncodedRun(head: string, offset: number): boolean {
|
|
||||||
const run = head.slice(offset);
|
|
||||||
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subtypes whose own name makes a poor label */
|
|
||||||
const LABEL_OVERRIDES: Record<string, string> = {
|
|
||||||
"text/plain": "TEXT",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
|
|
||||||
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
|
|
||||||
* as MS-EXCEL. Anything still too long falls back to the top-level type.
|
|
||||||
*/
|
|
||||||
export function labelForMime(mime: string): string {
|
|
||||||
const override = LABEL_OVERRIDES[mime];
|
|
||||||
if (override != null) {
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, subtype] = mime.split("/");
|
|
||||||
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
|
|
||||||
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
|
|
||||||
return word.toUpperCase();
|
|
||||||
}
|
|
||||||
return (type ?? mime).toUpperCase();
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { Completion } from "@codemirror/autocomplete";
|
|
||||||
import { EditorState, type TransactionSpec } from "@codemirror/state";
|
|
||||||
import type { EditorView } from "@codemirror/view";
|
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
|
|
||||||
|
|
||||||
describe("applyUrlCompletion", () => {
|
|
||||||
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
|
|
||||||
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
|
|
||||||
"http://rickandmortyapi.com/api/character",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("inserts a protocol when there is no existing suffix", () => {
|
|
||||||
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("replaces the full URL when accepting a saved URL", () => {
|
|
||||||
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
|
|
||||||
"https://new.example/api",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getUrlCompletionConfig", () => {
|
|
||||||
test("always includes protocols alongside saved URL options", () => {
|
|
||||||
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
|
|
||||||
|
|
||||||
expect(config.options.map((option) => option.label)).toEqual([
|
|
||||||
"http://",
|
|
||||||
"https://",
|
|
||||||
"https://example.com",
|
|
||||||
]);
|
|
||||||
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyCompletion(document: string, label: string, cursor: number) {
|
|
||||||
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
|
|
||||||
const view = {
|
|
||||||
state,
|
|
||||||
dispatch: (spec: TransactionSpec) => {
|
|
||||||
state = state.update(spec).state;
|
|
||||||
},
|
|
||||||
} as unknown as EditorView;
|
|
||||||
|
|
||||||
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
|
|
||||||
return state.doc.toString();
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
|
|
||||||
import type { EditorView } from "@codemirror/view";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import {
|
|
||||||
genericCompletion,
|
|
||||||
type GenericCompletion,
|
|
||||||
type GenericCompletionConfig,
|
|
||||||
} from "../genericCompletion";
|
|
||||||
|
|
||||||
const protocolOptions: GenericCompletionOption[] = [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getUrlCompletionConfig(
|
|
||||||
options: GenericCompletionOption[],
|
|
||||||
minMatch = 3,
|
|
||||||
): GenericCompletionConfig {
|
|
||||||
const urlOptions = [
|
|
||||||
...protocolOptions,
|
|
||||||
...options.filter(
|
|
||||||
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
minMatch,
|
|
||||||
options: urlOptions.map<GenericCompletion>((option) => ({
|
|
||||||
...option,
|
|
||||||
apply: applyUrlCompletion,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyUrlCompletion(
|
|
||||||
view: EditorView,
|
|
||||||
completion: Completion,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
) {
|
|
||||||
const isProtocol = /^https?:\/\/$/.test(completion.label);
|
|
||||||
const replaceTo = isProtocol
|
|
||||||
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
|
|
||||||
: view.state.doc.length;
|
|
||||||
|
|
||||||
view.dispatch({
|
|
||||||
...insertCompletionText(view.state, completion.label, from, replaceTo),
|
|
||||||
annotations: pickedCompletion.of(completion),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// Host is optional so URLs starting with `/` go straight to Path. Without this,
|
|
||||||
// the parser error-recovers past the leading `/` and consumes the first segment as
|
|
||||||
// Host (since Host's char class includes `:` for `host:port`), eating an initial
|
|
||||||
// `:name` placeholder like `/:foo/:bar`.
|
|
||||||
@top url { Protocol? Host? Path? Query? }
|
|
||||||
|
|
||||||
Path { ("/" PathSegment)+ }
|
|
||||||
|
|
||||||
Placeholder { ":" pathChars }
|
|
||||||
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
|
|
||||||
|
|
||||||
Query { "?" queryPair ("&" queryPair)* }
|
|
||||||
|
|
||||||
@tokens {
|
|
||||||
Protocol { $[a-zA-Z]+ "://" }
|
|
||||||
Host { $[a-zA-Z0-9-_.:\[\]]+ }
|
|
||||||
@precedence { Protocol, Host }
|
|
||||||
|
|
||||||
pathChars { ![/?#:]+ }
|
|
||||||
|
|
||||||
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@external propSource highlight from "./highlight"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parser } from "./url";
|
|
||||||
|
|
||||||
function expectValidParse(input: string) {
|
|
||||||
expect(parser.parse(input).toString()).not.toContain("⚠");
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeholderValues(input: string): string[] {
|
|
||||||
const values: string[] = [];
|
|
||||||
parser
|
|
||||||
.parse(input)
|
|
||||||
.cursor()
|
|
||||||
.iterate((node) => {
|
|
||||||
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("URL grammar Placeholder", () => {
|
|
||||||
test("recognizes path placeholders", () => {
|
|
||||||
expectValidParse("https://x.com/users/:id");
|
|
||||||
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats a colon suffix as literal path text", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats repeated colon suffixes as literal path text", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not recognize a colon in the middle of a plain path segment", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not recognize query parameters as path placeholders", () => {
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recognizes placeholders in a path fragment after a templated base URL", () => {
|
|
||||||
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
|
|
||||||
// as in `${[ URL ]}/x/:foo/:hello`.
|
|
||||||
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
|
|
||||||
":foo",
|
|
||||||
":hello",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
|
||||||
import {LRParser} from "@lezer/lr"
|
|
||||||
import {highlight} from "./highlight"
|
|
||||||
export const parser = LRParser.deserialize({
|
|
||||||
version: 14,
|
|
||||||
states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
|
|
||||||
stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
|
|
||||||
goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
|
|
||||||
nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
|
|
||||||
maxTerm: 17,
|
|
||||||
propSources: [highlight],
|
|
||||||
skippedNodes: [0],
|
|
||||||
repeatNodeCount: 3,
|
|
||||||
tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!QZ!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
|
|
||||||
tokenizers: [0, 1, 2],
|
|
||||||
topRules: {"url":[0,1]},
|
|
||||||
tokenPrec: 99
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { describe, expect, test, vi } from "vite-plus/test";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { HotkeyList } from "./HotkeyList";
|
|
||||||
|
|
||||||
vi.mock("./Hotkey", () => ({
|
|
||||||
Hotkey: ({ action }: { action: HotkeyAction }) =>
|
|
||||||
action === "sidebar.selected.move" ? null : <span>{action}</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./HotkeyLabel", () => ({
|
|
||||||
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("HotkeyList", () => {
|
|
||||||
test("keeps a grid cell for actions without a shortcut", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(markup).toContain(
|
|
||||||
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
IconButton as BaseIconButton,
|
|
||||||
type IconButtonProps as BaseIconButtonProps,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type IconButtonProps = BaseIconButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children:
|
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
|
||||||
selectable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyValueRows({ children, selectable }: Props) {
|
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
|
||||||
return (
|
|
||||||
<table
|
|
||||||
className={classNames(
|
|
||||||
"text-editor font-mono min-w-0 w-full mb-auto",
|
|
||||||
selectable &&
|
|
||||||
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{childArray.map((child, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<tr key={i}>{child}</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyValueRowProps {
|
|
||||||
label: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
align?: "top" | "middle";
|
|
||||||
labelClassName?: string;
|
|
||||||
labelColor?: "secondary" | "primary" | "info";
|
|
||||||
enableCopy?: boolean;
|
|
||||||
copyText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyValueRow({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
rightSlot,
|
|
||||||
leftSlot,
|
|
||||||
align = "top",
|
|
||||||
labelColor = "secondary",
|
|
||||||
labelClassName,
|
|
||||||
enableCopy,
|
|
||||||
copyText,
|
|
||||||
}: KeyValueRowProps) {
|
|
||||||
const textToCopy =
|
|
||||||
copyText ??
|
|
||||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
|
||||||
const copyTitle =
|
|
||||||
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
|
|
||||||
const resolvedRightSlot =
|
|
||||||
rightSlot ??
|
|
||||||
(enableCopy && textToCopy != null ? (
|
|
||||||
<CopyIconButton
|
|
||||||
text={textToCopy}
|
|
||||||
className="text-text-subtle"
|
|
||||||
size="2xs"
|
|
||||||
title={copyTitle}
|
|
||||||
iconSize="sm"
|
|
||||||
/>
|
|
||||||
) : null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td
|
|
||||||
className={classNames(
|
|
||||||
"select-none py-0.5 pr-2 h-full max-w-40",
|
|
||||||
align === "top" && "align-top",
|
|
||||||
align === "middle" && "align-middle",
|
|
||||||
labelClassName,
|
|
||||||
labelColor === "primary" && "text-primary",
|
|
||||||
labelColor === "secondary" && "text-text-subtle",
|
|
||||||
labelColor === "info" && "text-info",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="select-text cursor-text">{label}</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={classNames(
|
|
||||||
"select-none py-0.5 break-all max-w-60",
|
|
||||||
align === "top" && "align-top",
|
|
||||||
align === "middle" && "align-middle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
|
||||||
{leftSlot ?? <span aria-hidden />}
|
|
||||||
{children}
|
|
||||||
{resolvedRightSlot ? (
|
|
||||||
<div className="ml-1.5">{resolvedRightSlot}</div>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
|
||||||
|
|
||||||
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
|
||||||
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
|
||||||
if (typeof p.id === "string") {
|
|
||||||
return p as T & PairWithId;
|
|
||||||
}
|
|
||||||
return { ...p, id: p.id ?? generateId() };
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
|
||||||
import type { IconProps } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single control attached to a labelled separator, rendered between the label
|
|
||||||
* and the rule.
|
|
||||||
*
|
|
||||||
* Declared rather than passed as a node so the separator keeps ownership of the
|
|
||||||
* things that are easy to get wrong by hand: matching the label's colour, and
|
|
||||||
* staying out of the rule's way when the label is long.
|
|
||||||
*/
|
|
||||||
export interface SeparatorAction {
|
|
||||||
icon: IconProps["icon"];
|
|
||||||
/** Tooltip and accessible name. Required — the control is icon-only. */
|
|
||||||
title: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
dashed?: boolean;
|
|
||||||
className?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
color?: Color;
|
|
||||||
action?: SeparatorAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Separator({
|
|
||||||
color,
|
|
||||||
className,
|
|
||||||
dashed,
|
|
||||||
orientation = "horizontal",
|
|
||||||
children,
|
|
||||||
action,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
|
||||||
{children && (
|
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
|
||||||
)}
|
|
||||||
{action && (
|
|
||||||
<IconButton
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
className="shrink-0 mr-2 -ml-1"
|
|
||||||
// Forced, because the button itself sets `text-text` at full strength.
|
|
||||||
iconClassName="text-text-subtlest!"
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"opacity-60",
|
|
||||||
// Keep a stub of the line visible no matter how long the label is —
|
|
||||||
// `w-full` alone gets squeezed to nothing by a wide label.
|
|
||||||
orientation === "horizontal" && "min-w-8",
|
|
||||||
color == null && "border-border",
|
|
||||||
color === "primary" && "border-primary",
|
|
||||||
color === "secondary" && "border-secondary",
|
|
||||||
color === "success" && "border-success",
|
|
||||||
color === "notice" && "border-notice",
|
|
||||||
color === "warning" && "border-warning",
|
|
||||||
color === "danger" && "border-danger",
|
|
||||||
color === "info" && "border-info",
|
|
||||||
dashed && "border-dashed",
|
|
||||||
orientation === "horizontal" && "w-full h-0 border-t",
|
|
||||||
orientation === "vertical" && "h-full w-0 border-l",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
import type { AnyModel } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
import { Checkbox } from "./Checkbox";
|
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
|
||||||
import { PlainInput } from "./PlainInput";
|
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
|
||||||
import { Select } from "./Select";
|
|
||||||
import { SelectFile } from "../SelectFile";
|
|
||||||
|
|
||||||
type ModelKeyOfValue<T, V> = {
|
|
||||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
type SettingRowBaseProps = {
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
title: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
description,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
title: ReactNode | null;
|
|
||||||
}) {
|
|
||||||
const showHeader = title != null || description != null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={classNames(className, "w-full")}>
|
|
||||||
{showHeader && (
|
|
||||||
<div className="border-b border-border-subtle pb-2">
|
|
||||||
{title != null && <div className="text-text-subtle">{title}</div>}
|
|
||||||
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="[&>*:last-child]:border-b-0">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-disabled={disabled || undefined}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"@container border-b border-border-subtle py-4",
|
|
||||||
disabled && "opacity-disabled",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-1 gap-2",
|
|
||||||
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text text-text">{title}</div>
|
|
||||||
{description != null && (
|
|
||||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
|
||||||
controlClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingValue({
|
|
||||||
actions,
|
|
||||||
className,
|
|
||||||
copyText,
|
|
||||||
enableCopy = true,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
actions?: SettingValueAction[];
|
|
||||||
className?: string;
|
|
||||||
copyText?: string;
|
|
||||||
enableCopy?: boolean;
|
|
||||||
value: ReactNode;
|
|
||||||
}) {
|
|
||||||
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
|
|
||||||
const textToCopy = copyText ?? textValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<IconButton
|
|
||||||
key={action.title}
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{enableCopy && textToCopy != null && (
|
|
||||||
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingValueAction = {
|
|
||||||
icon: IconButtonProps["icon"];
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingRowBoolean({
|
|
||||||
checked,
|
|
||||||
checkboxSize = "md",
|
|
||||||
onChange,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
checkboxSize?: "sm" | "md";
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size={checkboxSize}
|
|
||||||
checked={checked}
|
|
||||||
disabled={props.disabled}
|
|
||||||
title={title}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={model[modelKey] as boolean}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowNumber({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "w-48!",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "number",
|
|
||||||
validate,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "number";
|
|
||||||
validate?: (value: string) => boolean;
|
|
||||||
value: number;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
validate={validate}
|
|
||||||
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as number}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowText({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "w-80!",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "text",
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value: string;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
onChange={onChange}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowText
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as string}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowFile({
|
|
||||||
buttonClassName,
|
|
||||||
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
|
|
||||||
directory,
|
|
||||||
filePath,
|
|
||||||
nameOverride,
|
|
||||||
noun,
|
|
||||||
onChange,
|
|
||||||
size = "xs",
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
buttonClassName?: string;
|
|
||||||
directory?: boolean;
|
|
||||||
filePath: string | null;
|
|
||||||
nameOverride?: string | null;
|
|
||||||
noun?: string;
|
|
||||||
onChange: (filePath: string | null) => void | Promise<void>;
|
|
||||||
size?: Parameters<typeof SelectFile>[0]["size"];
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} controlClassName={controlClassName} {...props}>
|
|
||||||
<SelectFile
|
|
||||||
directory={directory}
|
|
||||||
inline
|
|
||||||
hideLabel
|
|
||||||
label={typeof title === "string" ? title : noun}
|
|
||||||
size={size}
|
|
||||||
noun={noun}
|
|
||||||
nameOverride={nameOverride}
|
|
||||||
filePath={filePath}
|
|
||||||
className={buttonClassName}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowDirectory({
|
|
||||||
noun = "Directory",
|
|
||||||
...props
|
|
||||||
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
|
|
||||||
return <SettingRowFile directory noun={noun} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowSelect<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "w-48!",
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
selectClassName={selectClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelectControl<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "w-48!",
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
label={label}
|
|
||||||
size="sm"
|
|
||||||
className={selectClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingSelectControl<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingSelectControl
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowSelect<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowSelect
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingOverrideRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
onResetOverride,
|
|
||||||
overridden,
|
|
||||||
resetTitle = "Reset override",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onResetOverride: () => void;
|
|
||||||
overridden: boolean;
|
|
||||||
resetTitle?: string;
|
|
||||||
title: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
className={className}
|
|
||||||
controlClassName={controlClassName}
|
|
||||||
description={description}
|
|
||||||
disabled={disabled}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{title}
|
|
||||||
{overridden && (
|
|
||||||
<IconButton
|
|
||||||
icon="undo_2"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title={resetTitle}
|
|
||||||
className="text-text-subtle"
|
|
||||||
onClick={onResetOverride}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
|
|
||||||
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useKey } from "react-use";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
|
|
||||||
export interface ToastProps {
|
|
||||||
children: ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
timeout: number | null;
|
|
||||||
action?: (args: { hide: () => void }) => ReactNode;
|
|
||||||
icon?: ShowToastRequest["icon"] | null;
|
|
||||||
color?: ShowToastRequest["color"];
|
|
||||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
|
||||||
// past the default max height
|
|
||||||
dynamicHeight?: boolean;
|
|
||||||
// Hide the close button, for toasts that render their own dismiss action.
|
|
||||||
// Escape still closes the toast
|
|
||||||
hideDismiss?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
|
||||||
custom: null,
|
|
||||||
danger: "alert_triangle",
|
|
||||||
info: "info",
|
|
||||||
notice: "alert_triangle",
|
|
||||||
primary: "info",
|
|
||||||
secondary: "info",
|
|
||||||
success: "check_circle",
|
|
||||||
warning: "alert_triangle",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Toast({
|
|
||||||
children,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
timeout,
|
|
||||||
action,
|
|
||||||
icon,
|
|
||||||
color,
|
|
||||||
dynamicHeight,
|
|
||||||
hideDismiss,
|
|
||||||
}: ToastProps) {
|
|
||||||
const onCloseRef = useRef(onClose);
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onCloseRef.current = onClose;
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const cancelAutoHide = useCallback(() => {
|
|
||||||
if (timeoutRef.current == null) return;
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
setAutoHideCanceled(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || timeout == null || autoHideCanceled) return;
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
onCloseRef.current();
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current == null) return;
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
};
|
|
||||||
}, [autoHideCanceled, open, timeout]);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
"Escape",
|
|
||||||
() => {
|
|
||||||
if (!open) return;
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
[open],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
initial={{ opacity: 0, right: "-10%" }}
|
|
||||||
animate={{ opacity: 100, right: 0 }}
|
|
||||||
exit={{ opacity: 0, right: "-100%" }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className={classNames("bg-surface m-2 rounded-lg")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
`x-theme-toast x-theme-toast--${color}`,
|
|
||||||
"pointer-events-auto overflow-hidden",
|
|
||||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
|
||||||
"border border-border shadow-lg w-100",
|
|
||||||
)}
|
|
||||||
onFocusCapture={cancelAutoHide}
|
|
||||||
onKeyDownCapture={cancelAutoHide}
|
|
||||||
onPointerDownCapture={cancelAutoHide}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
|
||||||
hideDismiss ? "pr-3" : "pr-10",
|
|
||||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
|
||||||
<VStack space={2} className="w-full min-w-0">
|
|
||||||
<div className="select-auto">{children}</div>
|
|
||||||
{action?.({ hide: onClose })}
|
|
||||||
</VStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hideDismiss && (
|
|
||||||
<IconButton
|
|
||||||
color={color}
|
|
||||||
variant="border"
|
|
||||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
|
||||||
title="Dismiss"
|
|
||||||
icon="x"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{timeout != null && !autoHideCanceled && (
|
|
||||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
|
||||||
<m.div
|
|
||||||
className="bg-surface-highlight h-[3px]"
|
|
||||||
initial={{ width: "100%" }}
|
|
||||||
animate={{ width: "0%", opacity: 0.2 }}
|
|
||||||
transition={{ duration: timeout / 1000, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
|
||||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
|
|
||||||
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
|
|
||||||
const callbacks = useGitCallbacks(dir);
|
|
||||||
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
|
|
||||||
const log = useGitLog(dir, undefined, relaPath);
|
|
||||||
const commits = log.data ?? [];
|
|
||||||
const [selectedOid, setSelectedOid] = useState<string | null>(null);
|
|
||||||
const selectedCommit = useMemo(
|
|
||||||
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
|
|
||||||
[commits, selectedOid],
|
|
||||||
);
|
|
||||||
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commits.length === 0) {
|
|
||||||
setSelectedOid(null);
|
|
||||||
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
|
|
||||||
setSelectedOid(commits[0]?.oid ?? null);
|
|
||||||
}
|
|
||||||
}, [commits, selectedOid]);
|
|
||||||
|
|
||||||
const handleRestoreCommit = useCallback(
|
|
||||||
async (commit: GitCommit) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-file-history-entry",
|
|
||||||
title: "Restore File",
|
|
||||||
description: "This will restore the file to the selected commit.",
|
|
||||||
confirmText: "Restore",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
[relaPath, restoreFileFromCommit],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commits.length === 0 && !log.isLoading) {
|
|
||||||
return <EmptyStateText>No history for this file</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full px-2 pb-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="git-file-history-horizontal"
|
|
||||||
layout="horizontal"
|
|
||||||
defaultRatio={0.6}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
|
|
||||||
<div className="flex flex-col pt-1.5">
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<CommitListItem
|
|
||||||
key={commit.oid}
|
|
||||||
commit={commit}
|
|
||||||
selected={commit.oid === selectedCommit?.oid}
|
|
||||||
onSelect={() => setSelectedOid(commit.oid)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
|
|
||||||
{selectedCommit == null ? (
|
|
||||||
<EmptyStateText>Select a commit to view diff</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => handleRestoreCommit(selectedCommit)}
|
|
||||||
>
|
|
||||||
Restore File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DiffViewer
|
|
||||||
original={diff.data?.original ?? ""}
|
|
||||||
modified={diff.data?.modified ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitListItem({
|
|
||||||
commit,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
commit: GitCommit;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames(
|
|
||||||
"w-full min-w-0 text-left rounded-sm px-2 py-1.5",
|
|
||||||
selected && "bg-surface-active",
|
|
||||||
)}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="truncate flex-1">{commit.message || "No message"}</div>
|
|
||||||
<div className="text-text-subtle text-sm truncate">
|
|
||||||
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,681 +0,0 @@
|
|||||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useMemo } from "react";
|
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
|
|
||||||
import { showPrompt } from "../../lib/prompt";
|
|
||||||
import { showErrorToast, showToast } from "../../lib/toast";
|
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
|
||||||
import { handlePullResult, handlePushResult } from "./git-util";
|
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
|
||||||
|
|
||||||
const EMPTY_BRANCHES: string[] = [];
|
|
||||||
|
|
||||||
export function GitDropdown() {
|
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
||||||
if (workspaceMeta == null) return null;
|
|
||||||
|
|
||||||
if (workspaceMeta.settingSyncDir == null) {
|
|
||||||
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
|
||||||
const [refreshKey, regenerateKey] = useRandomKey();
|
|
||||||
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
|
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
|
||||||
const {
|
|
||||||
createBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
renameBranch,
|
|
||||||
mergeBranch,
|
|
||||||
push,
|
|
||||||
pull,
|
|
||||||
checkout,
|
|
||||||
resetChanges,
|
|
||||||
init,
|
|
||||||
} = useGitMutations(syncDir, callbacks);
|
|
||||||
|
|
||||||
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteOnlyBranches = useMemo(
|
|
||||||
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
|
|
||||||
[localBranches, remoteBranches],
|
|
||||||
);
|
|
||||||
const currentBranch = branchInfo.data?.headRefShorthand;
|
|
||||||
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
|
|
||||||
const ahead = branchInfo.data?.ahead ?? 0;
|
|
||||||
const behind = branchInfo.data?.behind ?? 0;
|
|
||||||
const initRepo = useCallback(() => {
|
|
||||||
init.mutate();
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(() => {
|
|
||||||
if (workspace == null || branchInfo.data == null) return [];
|
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
|
||||||
checkout.mutate(
|
|
||||||
{ branch, force },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
async onError(err) {
|
|
||||||
if (!force) {
|
|
||||||
// Checkout failed so ask user if they want to force it
|
|
||||||
const forceCheckout = await showConfirm({
|
|
||||||
id: "git-force-checkout",
|
|
||||||
title: "Conflicts Detected",
|
|
||||||
description:
|
|
||||||
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
|
|
||||||
confirmText: "Force Checkout",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (forceCheckout) {
|
|
||||||
tryCheckout(branch, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Checkout failed
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-checkout-error",
|
|
||||||
title: "Error checking out branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onSuccess(branchName) {
|
|
||||||
showToast({
|
|
||||||
id: "git-checkout-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Switched branch <InlineCode>{branchName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "View History...",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
showDialog({
|
|
||||||
id: "git-history",
|
|
||||||
size: "md",
|
|
||||||
title: "Commit History",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <HistoryDialog dir={syncDir} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manage Remotes...",
|
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
|
||||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
leftSlot: <Icon icon="git_branch_plus" />,
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-branch-name",
|
|
||||||
title: "Create Branch",
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Push",
|
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await push.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePushResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-push-error",
|
|
||||||
title: "Error pushing changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pull",
|
|
||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await pull.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePullResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-pull-error",
|
|
||||||
title: "Error pulling changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Commit...",
|
|
||||||
|
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
|
||||||
onSelect() {
|
|
||||||
showDialog({
|
|
||||||
id: "commit",
|
|
||||||
title: "Commit Changes",
|
|
||||||
size: "full",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reset Changes",
|
|
||||||
hidden: !hasChanges,
|
|
||||||
leftSlot: <Icon icon="rotate_ccw" />,
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-reset-changes",
|
|
||||||
title: "Reset Changes",
|
|
||||||
description: "This will discard all uncommitted changes. This cannot be undone.",
|
|
||||||
confirmText: "Reset",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await resetChanges.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-reset-success",
|
|
||||||
message: "Changes have been reset",
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-reset-error",
|
|
||||||
title: "Error resetting changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
|
|
||||||
...localBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
Merge into <InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
hidden: isCurrent,
|
|
||||||
async onSelect() {
|
|
||||||
await mergeBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-merged-branch",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Merged <InlineCode>{branch}</InlineCode> into{" "}
|
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-merged-branch-error",
|
|
||||||
title: "Error merging branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-new-branch-from",
|
|
||||||
title: "New Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Create a new branch from <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name, base: branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Rename...",
|
|
||||||
async onSelect() {
|
|
||||||
const newName = await showPrompt({
|
|
||||||
id: "git-rename-branch",
|
|
||||||
title: "Rename Branch",
|
|
||||||
label: "New Branch Name",
|
|
||||||
defaultValue: branch,
|
|
||||||
});
|
|
||||||
if (!newName || newName === branch) return;
|
|
||||||
|
|
||||||
await renameBranch.mutateAsync(
|
|
||||||
{ oldName: branch, newName },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-rename-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Renamed <InlineCode>{branch}</InlineCode> to{" "}
|
|
||||||
<InlineCode>{newName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-rename-branch-error",
|
|
||||||
title: "Error renaming branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", hidden: isCurrent },
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-branch",
|
|
||||||
title: "Delete Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-branch-error",
|
|
||||||
title: "Error deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.type === "not_fully_merged") {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "force-branch-delete",
|
|
||||||
title: "Branch not fully merged",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
|
||||||
</p>
|
|
||||||
<p>Do you want to delete it anyway?</p>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await deleteBranch.mutateAsync(
|
|
||||||
{ branch, force: true },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-force-delete-branch-error",
|
|
||||||
title: "Error force deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
...remoteOnlyBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-remote-branch",
|
|
||||||
title: "Delete Remote Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await deleteRemoteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-delete-remote-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-remote-branch-error",
|
|
||||||
title: "Error deleting remote branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
branchInfo.data,
|
|
||||||
checkout,
|
|
||||||
createBranch,
|
|
||||||
currentBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
hasChanges,
|
|
||||||
localBranches,
|
|
||||||
mergeBranch,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
remoteOnlyBranches,
|
|
||||||
renameBranch,
|
|
||||||
resetChanges,
|
|
||||||
syncDir,
|
|
||||||
workspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noRepo = branchInfo.error?.includes("not found");
|
|
||||||
if (noRepo) {
|
|
||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still loading
|
|
||||||
if (branchInfo.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
|
||||||
<GitMenuButton>
|
|
||||||
<InlineCode className="flex items-center gap-1">
|
|
||||||
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
|
||||||
{currentBranch}
|
|
||||||
</InlineCode>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{ahead > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-primary">↗</span>
|
|
||||||
{ahead}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{behind > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-info">↙</span>
|
|
||||||
{behind}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
|
|
||||||
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-hidden focus-visible:bg-surface-highlight",
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
|
|
||||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
||||||
key: "setup_sync",
|
|
||||||
fallback: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = (
|
|
||||||
<Banner color="info">
|
|
||||||
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
|
|
||||||
Git collaboration.
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
fullWidth
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
label: banner,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: "success",
|
|
||||||
label: "Open Workspace Settings",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => openWorkspaceSettings("settings"),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Hide This Message",
|
|
||||||
leftSlot: <Icon icon="eye_closed" />,
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-sync-menu-prompt",
|
|
||||||
title: "Hide Setup Message",
|
|
||||||
description: "You can configure filesystem sync or Git it in the workspace settings",
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<GitMenuButton>
|
|
||||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
||||||
<Icon icon="wrench" />
|
|
||||||
<div className="truncate">Setup FS Sync or Git</div>
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupGitDropdown({
|
|
||||||
workspaceId,
|
|
||||||
initRepo,
|
|
||||||
}: {
|
|
||||||
workspaceId: string;
|
|
||||||
initRepo: () => void;
|
|
||||||
}) {
|
|
||||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
||||||
key: "setup_git_repo",
|
|
||||||
fallback: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hidden == null || hidden[workspaceId]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
fullWidth
|
|
||||||
items={[
|
|
||||||
{ type: "content", label: banner },
|
|
||||||
{
|
|
||||||
label: "Initialize Git Repo",
|
|
||||||
leftSlot: <Icon icon="magic_wand" />,
|
|
||||||
onSelect: initRepo,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Hide This Message",
|
|
||||||
leftSlot: <Icon icon="eye_closed" />,
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-git-init-prompt",
|
|
||||||
title: "Hide Git Setup",
|
|
||||||
description: "You can initialize a git repo outside of Yaak to bring this back",
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<GitMenuButton>
|
|
||||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
||||||
<Icon icon="folder_git" />
|
|
||||||
<div className="truncate">Setup Git</div>
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useLocalStorage } from "react-use";
|
|
||||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
|
||||||
import { RadioDropdown } from "../core/RadioDropdown";
|
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
|
||||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
|
||||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
|
||||||
baseRequest: HttpRequest;
|
|
||||||
onChange: (body: HttpRequest["body"]) => void;
|
|
||||||
request: HttpRequest;
|
|
||||||
};
|
|
||||||
|
|
||||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
|
||||||
|
|
||||||
// How much of the end of a schema filename is pinned when middle-truncating it.
|
|
||||||
// Enough to keep the extension and a little of the name before it.
|
|
||||||
const FILE_NAME_TAIL_CHARS = 12;
|
|
||||||
|
|
||||||
export function GraphQLEditor(props: Props) {
|
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
|
||||||
// key on the request ID as a workaround for now.
|
|
||||||
return <GraphQLEditorInner key={props.request.id} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
|
|
||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
|
||||||
Record<string, boolean>
|
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
|
||||||
const {
|
|
||||||
schema,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
clear,
|
|
||||||
loadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
} = useIntrospectGraphQL(baseRequest, {
|
|
||||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Last path segment, for display only. The host owns real path semantics; this
|
|
||||||
// just needs something short enough to label the divider with.
|
|
||||||
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
|
|
||||||
|
|
||||||
// Selecting a file is all it takes — the request's source becomes that file,
|
|
||||||
// which is what keeps automatic introspection from overwriting it.
|
|
||||||
const handleLoadFromFile = useCallback(async () => {
|
|
||||||
const selected = await platform.dialog.open({
|
|
||||||
title: "Load GraphQL Schema",
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "GraphQL Schema",
|
|
||||||
extensions: ["graphql", "graphqls", "gql", "json"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
await loadFromFile(selected);
|
|
||||||
}, [loadFromFile]);
|
|
||||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
|
||||||
query: string;
|
|
||||||
variables: string | undefined;
|
|
||||||
operationName?: string;
|
|
||||||
}>(() => {
|
|
||||||
// Migrate text bodies to GraphQL format
|
|
||||||
// NOTE: This is how GraphQL used to be stored
|
|
||||||
return normalizeGraphQLBody(request.body);
|
|
||||||
}, [extraEditorProps.forceUpdateKey]);
|
|
||||||
|
|
||||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
|
||||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
|
||||||
const parsedOperationNames = useMemo(
|
|
||||||
() => parseGraphQLOperationNames(currentBody.query),
|
|
||||||
[currentBody.query],
|
|
||||||
);
|
|
||||||
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
|
||||||
|
|
||||||
const handleChangeQuery = useCallback(
|
|
||||||
(query: string) => {
|
|
||||||
setCurrentBody(({ variables, operationName }) => {
|
|
||||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
|
||||||
onChange(newBody);
|
|
||||||
return newBody;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange, setCurrentBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeVariables = useCallback(
|
|
||||||
(variables: string) => {
|
|
||||||
setCurrentBody(({ query, operationName }) => {
|
|
||||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
|
||||||
onChange(newBody);
|
|
||||||
return newBody;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange, setCurrentBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeOperationName = useCallback(
|
|
||||||
(operationName: string) => {
|
|
||||||
setCurrentBody(({ query, variables }) => {
|
|
||||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
|
||||||
onChange(newBody);
|
|
||||||
return newBody;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange, setCurrentBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (parsedOperationNames == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
|
|
||||||
const operationName = operationNames[0];
|
|
||||||
if (currentBody.operationName === operationName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentBody(({ query, variables }) => {
|
|
||||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
|
||||||
onChange(newBody);
|
|
||||||
return newBody;
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
currentBody.operationName,
|
|
||||||
onChange,
|
|
||||||
operationNames,
|
|
||||||
parsedOperationNames,
|
|
||||||
setCurrentBody,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const actions = useMemo<EditorProps["actions"]>(
|
|
||||||
() => [
|
|
||||||
operationNames.length > 0 ? (
|
|
||||||
<div key="operation" className="opacity-100!">
|
|
||||||
<RadioDropdown
|
|
||||||
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
|
||||||
onChange={handleChangeOperationName}
|
|
||||||
items={[
|
|
||||||
{ type: "separator", label: "Operation Name" },
|
|
||||||
{
|
|
||||||
label: <span className="text-text-subtle italic">Not specified</span>,
|
|
||||||
value: OPERATION_NAME_NOT_SPECIFIED,
|
|
||||||
},
|
|
||||||
...operationNames.map((operationName) => ({
|
|
||||||
label: operationName,
|
|
||||||
value: operationName,
|
|
||||||
})),
|
|
||||||
] satisfies RadioDropdownItem<string>[]}
|
|
||||||
>
|
|
||||||
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
|
||||||
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
|
||||||
<span className="text-text-subtle italic">Not specified</span>
|
|
||||||
) : (
|
|
||||||
currentBody.operationName ?? operationNames[0]
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
<div key="introspection" className="opacity-100!">
|
|
||||||
{schema === undefined ? null /* Initializing */ : (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
...((schema != null
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Clear Schema",
|
|
||||||
onSelect: clear,
|
|
||||||
color: "danger",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []) satisfies DropdownItem[]),
|
|
||||||
{
|
|
||||||
// Labels the source actions below it, so the menu says where the
|
|
||||||
// schema came from without spending a row on it.
|
|
||||||
type: "separator",
|
|
||||||
hidden: schema == null && filePath == null,
|
|
||||||
label:
|
|
||||||
fileName == null || filePath == null ? undefined : (
|
|
||||||
// Middle truncation: the head shrinks and ellipsizes while the
|
|
||||||
// tail is pinned, so the extension always survives. Full path
|
|
||||||
// on hover.
|
|
||||||
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
|
|
||||||
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
|
|
||||||
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
action:
|
|
||||||
filePath == null
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
icon: "folder_symlink",
|
|
||||||
title: revealInFinderText,
|
|
||||||
onClick: () => platform.revealItemInDir(filePath),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hidden: !error,
|
|
||||||
label: (
|
|
||||||
<Banner color="danger">
|
|
||||||
<p className="mb-1">Schema introspection failed</p>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="danger"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
title: "Introspection Failed",
|
|
||||||
size: "sm",
|
|
||||||
id: "introspection-failed",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<>
|
|
||||||
<FormattedError>{error ?? "unknown"}</FormattedError>
|
|
||||||
<div className="w-full my-4">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
await refetch();
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Retry Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Error
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
type: "content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// One refresh action for either source: re-read the file, or
|
|
||||||
// re-introspect the server.
|
|
||||||
label: "Reload Schema",
|
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
// Failures surface through the hook's error state either way.
|
|
||||||
onSelect: async () => {
|
|
||||||
if (filePath != null) await reloadFromFile();
|
|
||||||
else await refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
|
||||||
leftSlot: <Icon icon="import" />,
|
|
||||||
onSelect: handleLoadFromFile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hidden: filePath == null,
|
|
||||||
label: "Stop Using File",
|
|
||||||
leftSlot: <Icon icon="x" />,
|
|
||||||
onSelect: removeSchemaFile,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Settings" },
|
|
||||||
{
|
|
||||||
// Governs both sources: re-introspecting the server, and
|
|
||||||
// re-reading the file when the request is opened.
|
|
||||||
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => {
|
|
||||||
setAutoIntrospectDisabled({
|
|
||||||
...autoIntrospectDisabled,
|
|
||||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
autoIntrospectDisabled?.[baseRequest.id]
|
|
||||||
? "check_square_unchecked"
|
|
||||||
: "check_square_checked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title="Refetch Schema"
|
|
||||||
isLoading={isLoading}
|
|
||||||
color={error ? "danger" : "default"}
|
|
||||||
forDropdown
|
|
||||||
>
|
|
||||||
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</div>,
|
|
||||||
// Sits after the schema control it depends on. Always rendered, disabled
|
|
||||||
// without a schema, so the row never changes shape.
|
|
||||||
<div key="documentation" className="opacity-100!">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
icon="book_open_text"
|
|
||||||
disabled={schema == null}
|
|
||||||
title={
|
|
||||||
schema == null
|
|
||||||
? "Documentation unavailable without a schema"
|
|
||||||
: isDocOpen
|
|
||||||
? "Hide Documentation"
|
|
||||||
: "Show Documentation"
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
|
||||||
...v,
|
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
schema,
|
|
||||||
clear,
|
|
||||||
error,
|
|
||||||
currentBody.operationName,
|
|
||||||
handleChangeOperationName,
|
|
||||||
isDocOpen,
|
|
||||||
isLoading,
|
|
||||||
operationNames,
|
|
||||||
refetch,
|
|
||||||
handleLoadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
fileName,
|
|
||||||
autoIntrospectDisabled,
|
|
||||||
baseRequest.id,
|
|
||||||
setGraphqlDocStateAtomValue,
|
|
||||||
request.id,
|
|
||||||
setAutoIntrospectDisabled,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
|
|
||||||
<Editor
|
|
||||||
language="graphql"
|
|
||||||
heightMode="auto"
|
|
||||||
graphQLSchema={schema}
|
|
||||||
format={tryFormatGraphql}
|
|
||||||
defaultValue={currentBody.query}
|
|
||||||
onChange={handleChangeQuery}
|
|
||||||
placeholder="..."
|
|
||||||
actions={actions}
|
|
||||||
stateKey={`graphql_body.${request.id}`}
|
|
||||||
{...extraEditorProps}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-20">
|
|
||||||
<Separator dashed className="pb-1">
|
|
||||||
Variables
|
|
||||||
</Separator>
|
|
||||||
<Editor
|
|
||||||
language="json"
|
|
||||||
heightMode="auto"
|
|
||||||
defaultValue={currentBody.variables}
|
|
||||||
onChange={handleChangeVariables}
|
|
||||||
placeholder="{}"
|
|
||||||
stateKey={`graphql_vars.${request.id}`}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
{...extraEditorProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGraphQLBody(body: {
|
|
||||||
query: string;
|
|
||||||
variables: string | undefined;
|
|
||||||
operationName?: string;
|
|
||||||
}) {
|
|
||||||
const result: {
|
|
||||||
query: string;
|
|
||||||
variables: string | undefined;
|
|
||||||
operationName?: string;
|
|
||||||
} = {
|
|
||||||
query: body.query,
|
|
||||||
variables: body.variables || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof body.operationName === "string") {
|
|
||||||
result.operationName = body.operationName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** A URL for the body the host already stored. */
|
|
||||||
bodyUrl?: string;
|
|
||||||
data?: Uint8Array;
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
|
||||||
const [src, setSrc] = useState<string>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bodyUrl) {
|
|
||||||
setSrc(bodyUrl);
|
|
||||||
} else if (data) {
|
|
||||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
|
||||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSrc(objectUrl);
|
|
||||||
return () => URL.revokeObjectURL(objectUrl);
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
|
||||||
}, [bodyUrl, data, mimeType]);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
return <audio className="w-full" controls src={src} />;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { describe, expect, test, vi } from "vite-plus/test";
|
|
||||||
import { CsvViewerInner } from "./CsvViewer";
|
|
||||||
|
|
||||||
vi.mock("@yaakapp-internal/ui", () => ({
|
|
||||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
|
||||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
|
||||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
|
||||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
|
||||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
|
||||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("CsvViewer", () => {
|
|
||||||
test("renders columns that extend beyond the first row", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<CsvViewerInner
|
|
||||||
text={[
|
|
||||||
"startDate,2026-02-03T00:00-03:00",
|
|
||||||
"endDate,2026-02-03T23:59:59-03:00",
|
|
||||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
|
||||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
|
||||||
].join("\n")}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(markup).toContain("ID de usuario");
|
|
||||||
expect(markup).toContain("42041");
|
|
||||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
|
|
||||||
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { Fragment, useMemo, useState } from "react";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
|
||||||
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
|
||||||
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
|
|
||||||
import {
|
|
||||||
sseSummaryResultKeyPathAutocomplete,
|
|
||||||
useSseSummaryResultKeyPath,
|
|
||||||
} from "../../hooks/useSseSummaryResultKeyPath";
|
|
||||||
import { isJSON } from "../../lib/contentType";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Markdown } from "../Markdown";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
|
||||||
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
|
||||||
import { EventViewerRow } from "../core/EventViewerRow";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { IconTooltip } from "../core/IconTooltip";
|
|
||||||
import { Input } from "../core/Input";
|
|
||||||
import { Select } from "../core/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
|
|
||||||
|
|
||||||
export function EventStreamViewer({ response }: Props) {
|
|
||||||
return (
|
|
||||||
<Fragment
|
|
||||||
key={response.id} // force a refresh when the response changes
|
|
||||||
>
|
|
||||||
<ActualEventStreamViewer response={response} />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActualEventStreamViewer({ response }: Props) {
|
|
||||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
|
||||||
const filterEventPreviewsSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_filter_event_previews", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const applyToDetailsSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_apply_to_details", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const renderMarkdownSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_render_markdown", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const summarySettings = useSseSummaryResultKeyPath({ response });
|
|
||||||
const events = useResponseBodyEventSource(response);
|
|
||||||
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
|
|
||||||
const showExtractedText = summarySettings.resultKeyPath != null;
|
|
||||||
const showResultKeyPathWarning =
|
|
||||||
showExtractedText &&
|
|
||||||
summary.data != null &&
|
|
||||||
summary.data.fragmentCount === 0 &&
|
|
||||||
!summary.isFetching &&
|
|
||||||
summary.error == null;
|
|
||||||
|
|
||||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
|
||||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
|
||||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
|
||||||
const settingsItems = useMemo<DropdownItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
label: "Apply to Previews",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
filterEventPreviewsSetting.value === true
|
|
||||||
? "check_square_checked"
|
|
||||||
: "check_square_unchecked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Apply to Details",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
applyToDetailsSetting.value === true
|
|
||||||
? "check_square_checked"
|
|
||||||
: "check_square_unchecked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
applyToDetailsSetting,
|
|
||||||
filterEventPreviewsSetting,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
|
|
||||||
<div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
|
|
||||||
<Select
|
|
||||||
name={`sse-summary-result-key-path-enabled::${response.requestId}`}
|
|
||||||
label="Extracted text"
|
|
||||||
hideLabel
|
|
||||||
size="xs"
|
|
||||||
value={summarySettings.enabled ? "jsonpath" : "off"}
|
|
||||||
options={[
|
|
||||||
{ label: "Full events", value: "off" },
|
|
||||||
{ label: "JSONPath", value: "jsonpath" },
|
|
||||||
]}
|
|
||||||
onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{summarySettings.enabled && (
|
|
||||||
<>
|
|
||||||
<div className="min-w-40 flex-1">
|
|
||||||
<Input
|
|
||||||
label="Result JSON path"
|
|
||||||
hideLabel
|
|
||||||
size="xs"
|
|
||||||
autocomplete={sseSummaryResultKeyPathAutocomplete}
|
|
||||||
defaultValue={summarySettings.resultKeyPathInputValue}
|
|
||||||
forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
|
|
||||||
placeholder="$.choices[0].delta.content"
|
|
||||||
rightSlot={
|
|
||||||
showResultKeyPathWarning ? (
|
|
||||||
<div className="flex items-center px-2">
|
|
||||||
<IconTooltip
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="alert_triangle"
|
|
||||||
iconColor="notice"
|
|
||||||
content="No text fragments matched this JSONPath."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
stateKey={`sse-summary-result-key-path::${response.requestId}`}
|
|
||||||
tint={showResultKeyPathWarning ? "notice" : undefined}
|
|
||||||
onChange={summarySettings.setResultKeyPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Dropdown items={settingsItems}>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
icon="settings"
|
|
||||||
title="Extracted text settings"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
storageKey={`sse_extracted_text::${response.requestId}`}
|
|
||||||
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
|
|
||||||
minHeightPx={72}
|
|
||||||
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="min-h-0">
|
|
||||||
<EventViewer
|
|
||||||
events={events.data ?? []}
|
|
||||||
getEventKey={(_, index) => String(index)}
|
|
||||||
error={events.error ? String(events.error) : null}
|
|
||||||
splitLayoutStorageKey="sse_events"
|
|
||||||
defaultRatio={0.4}
|
|
||||||
renderRow={({ event, index, isActive, onClick }) => (
|
|
||||||
<EventViewerRow
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
|
|
||||||
content={
|
|
||||||
<HStack space={2} className="items-center">
|
|
||||||
<EventLabels event={event} index={index} isActive={isActive} />
|
|
||||||
<span className="truncate text-xs">
|
|
||||||
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
|
|
||||||
</span>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderDetail={({ event, index, onClose }) => (
|
|
||||||
<EventDetail
|
|
||||||
event={event}
|
|
||||||
index={index}
|
|
||||||
applyJsonPath={applyToDetails}
|
|
||||||
resultKeyPath={summarySettings.resultKeyPath}
|
|
||||||
showLarge={showLarge}
|
|
||||||
showingLarge={showingLarge}
|
|
||||||
setShowLarge={setShowLarge}
|
|
||||||
setShowingLarge={setShowingLarge}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={
|
|
||||||
showExtractedText
|
|
||||||
? ({ style }) => (
|
|
||||||
<SseSummaryFooter
|
|
||||||
style={style}
|
|
||||||
error={summary.error ? String(summary.error) : null}
|
|
||||||
isLoading={summary.isLoading && summary.data == null}
|
|
||||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
|
||||||
renderMarkdown={renderMarkdown}
|
|
||||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
|
||||||
summary={summary.data?.summary ?? ""}
|
|
||||||
fragmentCount={summary.data?.fragmentCount ?? 0}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SseSummaryFooter({
|
|
||||||
error,
|
|
||||||
fragmentCount,
|
|
||||||
isLoading,
|
|
||||||
onRenderMarkdownChange,
|
|
||||||
renderMarkdown,
|
|
||||||
resultKeyPath,
|
|
||||||
style,
|
|
||||||
summary,
|
|
||||||
}: {
|
|
||||||
error: string | null;
|
|
||||||
fragmentCount: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
|
|
||||||
renderMarkdown: boolean;
|
|
||||||
resultKeyPath: string;
|
|
||||||
style: CSSProperties;
|
|
||||||
summary: string;
|
|
||||||
}) {
|
|
||||||
const hasSummary = fragmentCount > 0;
|
|
||||||
const actions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: "sse-summary-format",
|
|
||||||
label: "Extracted text format",
|
|
||||||
type: "select" as const,
|
|
||||||
value: renderMarkdown ? "markdown" : "text",
|
|
||||||
options: [
|
|
||||||
{ label: "Text", value: "text" },
|
|
||||||
{ label: "Markdown", value: "markdown" },
|
|
||||||
],
|
|
||||||
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[onRenderMarkdownChange, renderMarkdown],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
|
|
||||||
>
|
|
||||||
<div className="pt-2">
|
|
||||||
<EventDetailHeader
|
|
||||||
actions={actions}
|
|
||||||
title="Extracted Text"
|
|
||||||
copyText={hasSummary ? summary : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"min-h-0 py-2 overflow-auto",
|
|
||||||
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{error != null ? (
|
|
||||||
<span className="text-danger">{error}</span>
|
|
||||||
) : isLoading ? (
|
|
||||||
<span className="italic text-text-subtlest">Loading extracted text...</span>
|
|
||||||
) : hasSummary ? (
|
|
||||||
renderMarkdown ? (
|
|
||||||
<div className="min-h-0">
|
|
||||||
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<pre className="font-mono whitespace-pre-wrap wrap-break-word select-auto cursor-auto">
|
|
||||||
{summary}
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<EmptyStateText className="gap-1.5">
|
|
||||||
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
|
|
||||||
</EmptyStateText>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventPreview(
|
|
||||||
event: ServerSentEvent,
|
|
||||||
resultKeyPath: string | null,
|
|
||||||
filterEventPreview: boolean,
|
|
||||||
): string {
|
|
||||||
if (filterEventPreview && resultKeyPath != null) {
|
|
||||||
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.data.slice(0, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventDetail({
|
|
||||||
applyJsonPath,
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
resultKeyPath,
|
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
applyJsonPath: boolean;
|
|
||||||
event: ServerSentEvent;
|
|
||||||
index: number;
|
|
||||||
resultKeyPath: string | null;
|
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const detailText = useMemo(
|
|
||||||
() =>
|
|
||||||
applyJsonPath && resultKeyPath != null
|
|
||||||
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
|
|
||||||
: event.data,
|
|
||||||
[applyJsonPath, event.data, resultKeyPath],
|
|
||||||
);
|
|
||||||
const language = useMemo<"text" | "json">(() => {
|
|
||||||
if (!detailText) return "text";
|
|
||||||
return isJSON(detailText) ? "json" : "text";
|
|
||||||
}, [detailText]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<EventDetailHeader
|
|
||||||
title="Message Received"
|
|
||||||
prefix={<EventLabels event={event} index={index} />}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
{!showLarge && detailText.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<FormattedEditor language={language} text={detailText} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormattedEditor({ text, language }: { text: string; language: EditorProps["language"] }) {
|
|
||||||
const formatted = useFormatText({ text, language, pretty: true });
|
|
||||||
if (formatted == null) return null;
|
|
||||||
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventLabels({
|
|
||||||
className,
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
isActive,
|
|
||||||
}: {
|
|
||||||
event: ServerSentEvent;
|
|
||||||
index: number;
|
|
||||||
className?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<HStack space={1.5} alignItems="center" className={className}>
|
|
||||||
<EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
|
|
||||||
{event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
|
|
||||||
return (
|
|
||||||
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
|
|
||||||
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
|
|
||||||
<span className="relative">{children}</span>
|
|
||||||
</InlineCode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type Props = { className?: string; mimeType?: string } & (
|
|
||||||
| {
|
|
||||||
/** A URL for the body the host already stored. */
|
|
||||||
bodyUrl: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
data: ArrayBuffer;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
|
||||||
const [src, setSrc] = useState<string>();
|
|
||||||
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
|
||||||
const data = "data" in props ? props.data : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bodyUrl != null) {
|
|
||||||
setSrc(bodyUrl);
|
|
||||||
} else if (data != null) {
|
|
||||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSrc(objectUrl);
|
|
||||||
return () => URL.revokeObjectURL(objectUrl);
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
|
||||||
}, [bodyUrl, data, mimeType]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt="Response preview"
|
|
||||||
className={classNames(className, "max-w-full max-h-full")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
|
||||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
recentFilters: RecentFilter[];
|
|
||||||
activeFilter: string | null;
|
|
||||||
onSelect: (value: string) => void;
|
|
||||||
onRemove: (value: string) => void;
|
|
||||||
onTogglePin: (value: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentFiltersDropdown({
|
|
||||||
recentFilters,
|
|
||||||
activeFilter,
|
|
||||||
onSelect,
|
|
||||||
onRemove,
|
|
||||||
onTogglePin,
|
|
||||||
onClear,
|
|
||||||
}: Props) {
|
|
||||||
const pinned = recentFilters.filter((f) => f.pinned);
|
|
||||||
const unpinned = recentFilters.filter((f) => !f.pinned);
|
|
||||||
|
|
||||||
const toItem = (filter: RecentFilter): DropdownItem => ({
|
|
||||||
label: (
|
|
||||||
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
|
||||||
{filter.value}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
|
||||||
onSelect: () => onSelect(filter.value),
|
|
||||||
submenuTrigger: "button",
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: filter.pinned ? "Unpin" : "Pin",
|
|
||||||
icon: filter.pinned ? "unpin" : "pin",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => onTogglePin(filter.value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Remove",
|
|
||||||
icon: "trash",
|
|
||||||
color: "danger",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => onRemove(filter.value),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const items: DropdownItem[] = [];
|
|
||||||
|
|
||||||
if (recentFilters.length === 0) {
|
|
||||||
items.push({
|
|
||||||
type: "content",
|
|
||||||
label: (
|
|
||||||
<span className="block px-4 py-1 text-sm text-text-subtle">
|
|
||||||
Filters you use are remembered here
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pinned.length > 0) {
|
|
||||||
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unpinned.length > 0) {
|
|
||||||
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentFilters.length > 0) {
|
|
||||||
items.push(
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Clear All",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
color: "danger",
|
|
||||||
onSelect: onClear,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon="filter"
|
|
||||||
title="Recent filters"
|
|
||||||
iconColor="secondary"
|
|
||||||
className="w-8 ml-0.5 mr-1 h-auto!"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Children, useCallback, useMemo } from "react";
|
|
||||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
|
||||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
|
||||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Input } from "../core/Input";
|
|
||||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
text: string;
|
|
||||||
language: EditorProps["language"];
|
|
||||||
stateKey: string | null;
|
|
||||||
pretty?: boolean;
|
|
||||||
className?: string;
|
|
||||||
footerActions?: ReactNode;
|
|
||||||
filter?: ResponseFilterApi;
|
|
||||||
filterResult?: {
|
|
||||||
data: string | null | undefined;
|
|
||||||
isPending: boolean;
|
|
||||||
error: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextViewer({
|
|
||||||
language,
|
|
||||||
text,
|
|
||||||
stateKey,
|
|
||||||
pretty,
|
|
||||||
className,
|
|
||||||
footerActions,
|
|
||||||
filter,
|
|
||||||
filterResult,
|
|
||||||
}: Props) {
|
|
||||||
const canFilter =
|
|
||||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
|
||||||
const isSearching = filter?.isSearching ?? false;
|
|
||||||
const appliedFilter = filter?.appliedFilter ?? null;
|
|
||||||
const resultError = filterResult?.error ?? false;
|
|
||||||
|
|
||||||
const handleFilterKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
if (filter == null) return;
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
filter.toggleSearch();
|
|
||||||
} else if (e.key === "Enter" && filter.filterText != null) {
|
|
||||||
filter.applyFilter(filter.filterText);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
|
||||||
|
|
||||||
if (!canFilter) return nodes;
|
|
||||||
|
|
||||||
if (isSearching) {
|
|
||||||
nodes.push(
|
|
||||||
<div key="input" className="w-full opacity-100!">
|
|
||||||
<Input
|
|
||||||
key={filter.stateKey ?? "filter"}
|
|
||||||
validate={!resultError}
|
|
||||||
hideLabel
|
|
||||||
autoFocus
|
|
||||||
containerClassName="bg-surface"
|
|
||||||
size="sm"
|
|
||||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
|
||||||
label="Filter expression"
|
|
||||||
name="filter"
|
|
||||||
defaultValue={filter.filterText}
|
|
||||||
forceUpdateKey={filter.filterUpdateKey}
|
|
||||||
onKeyDown={handleFilterKeyDown}
|
|
||||||
onChange={filter.setFilterText}
|
|
||||||
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
|
||||||
leftSlot={
|
|
||||||
<div className="py-0.5 flex">
|
|
||||||
<RecentFiltersDropdown
|
|
||||||
recentFilters={filter.recentFilters}
|
|
||||||
activeFilter={filter.appliedFilter}
|
|
||||||
onSelect={filter.replaceFilter}
|
|
||||||
onRemove={filter.removeRecentFilter}
|
|
||||||
onTogglePin={filter.togglePinRecentFilter}
|
|
||||||
onClear={filter.clearRecentFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
rightSlot={
|
|
||||||
<div className="py-0.5 flex">
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon="x"
|
|
||||||
title="Close filter"
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={filter.toggleSearch}
|
|
||||||
className="w-8 mr-0.5 h-auto!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nodes.push(
|
|
||||||
<IconButton
|
|
||||||
key="icon"
|
|
||||||
size="sm"
|
|
||||||
isLoading={filterResult?.isPending ?? false}
|
|
||||||
icon="filter"
|
|
||||||
title="Filter response"
|
|
||||||
onClick={filter.toggleSearch}
|
|
||||||
className="border border-border-subtle!"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes;
|
|
||||||
}, [
|
|
||||||
canFilter,
|
|
||||||
footerActions,
|
|
||||||
filter,
|
|
||||||
filterResult?.isPending,
|
|
||||||
resultError,
|
|
||||||
isSearching,
|
|
||||||
language,
|
|
||||||
handleFilterKeyDown,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
|
||||||
if (formattedBody == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: string;
|
|
||||||
if (appliedFilter) {
|
|
||||||
if (resultError) {
|
|
||||||
body = "";
|
|
||||||
} else {
|
|
||||||
body = filterResult?.data != null ? filterResult.data : "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body = formattedBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode unicode sequences in the text to readable characters
|
|
||||||
if (language === "json" && pretty) {
|
|
||||||
body = decodeUnicodeLiterals(body);
|
|
||||||
body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
|
||||||
{appliedFilter && filter != null ? (
|
|
||||||
<AppliedFilterBar
|
|
||||||
filter={appliedFilter}
|
|
||||||
error={resultError}
|
|
||||||
onClear={() => filter.replaceFilter("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<Editor
|
|
||||||
readOnly
|
|
||||||
className={className}
|
|
||||||
defaultValue={body}
|
|
||||||
language={language}
|
|
||||||
actions={actions}
|
|
||||||
extraExtensions={extraExtensions}
|
|
||||||
stateKey={stateKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows what's actually filtering the body, which the filter box below can't convey
|
|
||||||
* once it holds an edited expression that hasn't been applied yet.
|
|
||||||
*/
|
|
||||||
function AppliedFilterBar({
|
|
||||||
filter,
|
|
||||||
error,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
filter: string;
|
|
||||||
error: boolean;
|
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
|
||||||
<HStack space={2} className="min-w-0">
|
|
||||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
|
||||||
<span className="truncate min-w-0" title={filter}>
|
|
||||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
|
||||||
{error && " (invalid expression)"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color={error ? "danger" : "info"}
|
|
||||||
className="ml-auto shrink-0"
|
|
||||||
onClick={onClear}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert \uXXXX to actual Unicode characters */
|
|
||||||
function decodeUnicodeLiterals(text: string): string {
|
|
||||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
|
||||||
const charCode = Number.parseInt(hex, 16);
|
|
||||||
return String.fromCharCode(charCode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** A URL for the body the host already stored. */
|
|
||||||
bodyUrl?: string;
|
|
||||||
data?: Uint8Array;
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
|
||||||
const [src, setSrc] = useState<string>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bodyUrl) {
|
|
||||||
setSrc(bodyUrl);
|
|
||||||
} else if (data) {
|
|
||||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSrc(objectUrl);
|
|
||||||
return () => URL.revokeObjectURL(objectUrl);
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
|
||||||
}, [bodyUrl, data, mimeType]);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
return <video className="w-full" controls src={src} />;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
grpcRequestsAtom,
|
|
||||||
httpRequestsAtom,
|
|
||||||
websocketRequestsAtom,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import { selectAtom } from "jotai/utils";
|
|
||||||
|
|
||||||
export const allRequestsAtom = atom((get) => [
|
|
||||||
...get(httpRequestsAtom),
|
|
||||||
...get(grpcRequestsAtom),
|
|
||||||
...get(websocketRequestsAtom),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function useAllRequests() {
|
|
||||||
return useAtomValue(allRequestsAtom);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stringArrayEqual = (a: string[], b: string[]) =>
|
|
||||||
a.length === b.length && a.every((v, i) => v === b[i]);
|
|
||||||
|
|
||||||
// Identity-stable derivations so subscribers don't recompute or re-render when
|
|
||||||
// unrelated request fields change (eg. every debounced edit of a request)
|
|
||||||
export const allRequestIdsAtom = selectAtom(
|
|
||||||
allRequestsAtom,
|
|
||||||
(requests) => requests.map((r) => r.id),
|
|
||||||
stringArrayEqual,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const allRequestUrlsAtom = selectAtom(
|
|
||||||
allRequestsAtom,
|
|
||||||
(requests) => {
|
|
||||||
const urls = new Set<string>();
|
|
||||||
for (const r of requests) {
|
|
||||||
if (r.url) urls.add(r.url);
|
|
||||||
}
|
|
||||||
return Array.from(urls);
|
|
||||||
},
|
|
||||||
stringArrayEqual,
|
|
||||||
);
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import type { Folder } from "@yaakapp-internal/models";
|
|
||||||
import { modelTypeLabel, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
|
||||||
import { IconTooltip } from "../components/core/IconTooltip";
|
|
||||||
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
|
||||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
|
||||||
import { capitalize } from "../lib/capitalize";
|
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { useHttpAuthenticationSummaries } from "./useHttpAuthentication";
|
|
||||||
import type { AuthenticatedModel } from "./useInheritedAuthentication";
|
|
||||||
import { useInheritedAuthentication } from "./useInheritedAuthentication";
|
|
||||||
import { useModelAncestors } from "./useModelAncestors";
|
|
||||||
|
|
||||||
export function useAuthTab<T extends string>(
|
|
||||||
tabValue: T,
|
|
||||||
model: AuthenticatedModel | null,
|
|
||||||
) {
|
|
||||||
const options = useAuthDropdownOptions(model);
|
|
||||||
|
|
||||||
return useMemo<TabItem[]>(() => {
|
|
||||||
if (model == null || options == null) return [];
|
|
||||||
|
|
||||||
const tab: TabItem = {
|
|
||||||
value: tabValue,
|
|
||||||
label: "Auth",
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [tab];
|
|
||||||
}, [model, options, tabValue]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthDropdownOptions(
|
|
||||||
model: AuthenticatedModel | null,
|
|
||||||
): Omit<RadioDropdownProps, "children"> | null {
|
|
||||||
const authentication = useHttpAuthenticationSummaries();
|
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
|
||||||
const ancestors = useModelAncestors(model);
|
|
||||||
const parentModel = ancestors[0] ?? null;
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
if (model == null) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: model.authenticationType,
|
|
||||||
items: [
|
|
||||||
...authentication.map((a) => ({
|
|
||||||
label: a.label || "UNKNOWN",
|
|
||||||
shortLabel: a.shortLabel,
|
|
||||||
value: a.name,
|
|
||||||
})),
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Inherit from Parent",
|
|
||||||
shortLabel:
|
|
||||||
inheritedAuth != null &&
|
|
||||||
inheritedAuth.authenticationType !== "none" ? (
|
|
||||||
<HStack space={1.5}>
|
|
||||||
{authentication.find(
|
|
||||||
(a) => a.name === inheritedAuth.authenticationType,
|
|
||||||
)?.shortLabel ?? "UNKNOWN"}
|
|
||||||
<IconTooltip
|
|
||||||
icon="zap_off"
|
|
||||||
iconSize="xs"
|
|
||||||
content="Authentication was inherited from an ancestor"
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
"Auth"
|
|
||||||
),
|
|
||||||
value: null,
|
|
||||||
},
|
|
||||||
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
|
|
||||||
],
|
|
||||||
itemsAfter: (() => {
|
|
||||||
const actions: (
|
|
||||||
| { type: "separator"; label: string }
|
|
||||||
| {
|
|
||||||
label: string;
|
|
||||||
leftSlot: React.ReactNode;
|
|
||||||
onSelect: () => Promise<void>;
|
|
||||||
}
|
|
||||||
)[] = [];
|
|
||||||
|
|
||||||
// Promote: move auth from current model up to parent
|
|
||||||
if (
|
|
||||||
parentModel &&
|
|
||||||
model.authenticationType &&
|
|
||||||
model.authenticationType !== "none" &&
|
|
||||||
(parentModel.authenticationType == null ||
|
|
||||||
parentModel.authenticationType === "none")
|
|
||||||
) {
|
|
||||||
actions.push(
|
|
||||||
{ type: "separator", label: "Actions" },
|
|
||||||
{
|
|
||||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
parentModel.model === "workspace"
|
|
||||||
? "corner_right_up"
|
|
||||||
: "folder_up"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "promote-auth-confirm",
|
|
||||||
title: "Promote Authentication",
|
|
||||||
confirmText: "Promote",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Move authentication config to{" "}
|
|
||||||
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await patchModel(model, {
|
|
||||||
authentication: {},
|
|
||||||
authenticationType: null,
|
|
||||||
});
|
|
||||||
await patchModel(parentModel, {
|
|
||||||
authentication: model.authentication,
|
|
||||||
authenticationType: model.authenticationType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parentModel.model === "folder") {
|
|
||||||
openFolderSettings(parentModel.id, "auth");
|
|
||||||
} else {
|
|
||||||
openWorkspaceSettings("auth");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy from ancestor: copy auth config down to current model
|
|
||||||
const ancestorWithAuth = ancestors.find(
|
|
||||||
(a) =>
|
|
||||||
a.authenticationType != null && a.authenticationType !== "none",
|
|
||||||
);
|
|
||||||
if (ancestorWithAuth) {
|
|
||||||
if (actions.length === 0) {
|
|
||||||
actions.push({ type: "separator", label: "Actions" });
|
|
||||||
}
|
|
||||||
actions.push({
|
|
||||||
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
ancestorWithAuth.model === "workspace"
|
|
||||||
? "corner_right_down"
|
|
||||||
: "folder_down"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "copy-auth-confirm",
|
|
||||||
title: "Copy Authentication",
|
|
||||||
confirmText: "Copy",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Copy{" "}
|
|
||||||
{authentication.find(
|
|
||||||
(a) => a.name === ancestorWithAuth.authenticationType,
|
|
||||||
)?.label ?? "authentication"}{" "}
|
|
||||||
config from{" "}
|
|
||||||
<InlineCode>
|
|
||||||
{resolvedModelName(ancestorWithAuth)}
|
|
||||||
</InlineCode>
|
|
||||||
? This will override the current authentication but will not
|
|
||||||
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await patchModel(model, {
|
|
||||||
authentication: { ...ancestorWithAuth.authentication },
|
|
||||||
authenticationType: ancestorWithAuth.authenticationType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.length > 0 ? actions : undefined;
|
|
||||||
})(),
|
|
||||||
onChange: async (authenticationType) => {
|
|
||||||
let authentication: Folder["authentication"] = model.authentication;
|
|
||||||
if (model.authenticationType !== authenticationType) {
|
|
||||||
authentication = {
|
|
||||||
// Reset auth if changing types
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await patchModel(model, { authentication, authenticationType });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
// The file a request's GraphQL schema is loaded from, or null when the schema
|
|
||||||
// comes from an introspection request.
|
|
||||||
//
|
|
||||||
// This is the *source*, not the schema. The introspection row it produces is a
|
|
||||||
// cache that expires on its own; this outlives it and regenerates it, the same
|
|
||||||
// way gRPC keeps its proto file list separate from a reflection result.
|
|
||||||
export function graphqlSchemaFileArgs(requestId: string | null) {
|
|
||||||
return {
|
|
||||||
namespace: "global" as const,
|
|
||||||
key: ["graphql_schema_file", requestId ?? "n/a"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGraphQLSchemaFile(requestId: string | null) {
|
|
||||||
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
|
||||||
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
|
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
|
||||||
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
|
||||||
import { useActiveEnvironment } from "./useActiveEnvironment";
|
|
||||||
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
|
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
|
|
||||||
const introspectionRequestBody = JSON.stringify({
|
|
||||||
query: getIntrospectionQuery(),
|
|
||||||
operationName: "IntrospectionQuery",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useIntrospectGraphQL(
|
|
||||||
baseRequest: HttpRequest,
|
|
||||||
options: { disabled?: boolean } = {},
|
|
||||||
) {
|
|
||||||
// Debounce the request because it can change rapidly, and we don't
|
|
||||||
// want to send so too many requests.
|
|
||||||
const debouncedRequest = useDebouncedValue(baseRequest);
|
|
||||||
const activeEnvironment = useActiveEnvironment();
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const [schema, setSchema] = useState<GraphQLSchema | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const introspection = useIntrospectionResult(baseRequest);
|
|
||||||
|
|
||||||
// The schema's source. Outlives the introspection row it produces, so a
|
|
||||||
// request configured with a file keeps working after the row is swept.
|
|
||||||
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
|
|
||||||
const filePath = schemaFile.value ?? null;
|
|
||||||
|
|
||||||
const upsertIntrospection = useCallback(
|
|
||||||
async (content: string | null) => {
|
|
||||||
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
|
||||||
requestId: baseRequest.id,
|
|
||||||
workspaceId: baseRequest.workspaceId,
|
|
||||||
content: content ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update local introspection
|
|
||||||
queryClient.setQueryData(["introspection", baseRequest.id], v);
|
|
||||||
},
|
|
||||||
[baseRequest.id, baseRequest.workspaceId, queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const args = {
|
|
||||||
...baseRequest,
|
|
||||||
bodyType: "application/json",
|
|
||||||
body: { text: introspectionRequestBody },
|
|
||||||
};
|
|
||||||
const { response, body } = await minPromiseMillis(
|
|
||||||
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
|
||||||
700,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
return setError(response.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The send hands back the only copy of the body — an unsaved response has
|
|
||||||
// nothing on disk and no row to read it back from
|
|
||||||
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
|
||||||
return setError(
|
|
||||||
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bodyText === "") {
|
|
||||||
return setError("Empty body returned in response");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Got introspection response for ${baseRequest.url}`, bodyText);
|
|
||||||
await upsertIntrospection(bodyText);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [activeEnvironment?.id, baseRequest, upsertIntrospection]);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip introspection if automatic is disabled and we already have one
|
|
||||||
if (options.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A request pointed at a file gets its schema from that file. Introspecting
|
|
||||||
// here would overwrite it on the next URL edit.
|
|
||||||
if (filePath != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refetch().catch(console.error);
|
|
||||||
}, [
|
|
||||||
baseRequest.id,
|
|
||||||
debouncedRequest.url,
|
|
||||||
debouncedRequest.method,
|
|
||||||
activeEnvironment?.id,
|
|
||||||
filePath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Clears the schema, not the source. Removing a file source is a separate
|
|
||||||
// action, because the source is what would rebuild this a moment later.
|
|
||||||
const clear = useCallback(async () => {
|
|
||||||
setError("");
|
|
||||||
setSchema(null);
|
|
||||||
await upsertIntrospection(null);
|
|
||||||
}, [upsertIntrospection]);
|
|
||||||
|
|
||||||
// Reads a schema file and produces an introspection row from it, the same way
|
|
||||||
// `refetch` produces one from a server. Does not touch the stored source.
|
|
||||||
const introspectFromFile = useCallback(
|
|
||||||
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const fileContent = await platform.files.readText(path);
|
|
||||||
const result = tryBuildIntrospectionFromFile(fileContent);
|
|
||||||
|
|
||||||
if ("error" in result) {
|
|
||||||
setError(result.error);
|
|
||||||
return { ok: false, error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertIntrospection(result.content);
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
// The host rejects with a bare string for a missing or unreadable path,
|
|
||||||
// so this can't assume an Error.
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
setError(message);
|
|
||||||
return { ok: false, error: message };
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[upsertIntrospection],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Points the request at a file and immediately builds its schema from it.
|
|
||||||
const loadFromFile = useCallback(
|
|
||||||
async (path: string) => {
|
|
||||||
const result = await introspectFromFile(path);
|
|
||||||
if (result.ok) await schemaFile.set(path);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[introspectFromFile, schemaFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reloadFromFile = useCallback(async () => {
|
|
||||||
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
|
|
||||||
return introspectFromFile(filePath);
|
|
||||||
}, [filePath, introspectFromFile]);
|
|
||||||
|
|
||||||
// The file-source counterpart of automatic introspection: re-read the file
|
|
||||||
// when the request is opened, so an edited schema is picked up without asking.
|
|
||||||
//
|
|
||||||
// A missing row is repaired even with the setting off — that is recovering
|
|
||||||
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
|
|
||||||
// make the schema disappear with no visible cause.
|
|
||||||
const reloadedFor = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (filePath == null || introspection.isLoading) return;
|
|
||||||
// Only attempt once per path, so an unreadable file doesn't spin.
|
|
||||||
if (reloadedFor.current === filePath) return;
|
|
||||||
|
|
||||||
const hasContent = (introspection.data?.content ?? "") !== "";
|
|
||||||
if (hasContent && options.disabled) return;
|
|
||||||
|
|
||||||
reloadedFor.current = filePath;
|
|
||||||
introspectFromFile(filePath).catch(console.error);
|
|
||||||
}, [
|
|
||||||
filePath,
|
|
||||||
introspection.data?.content,
|
|
||||||
introspection.isLoading,
|
|
||||||
introspectFromFile,
|
|
||||||
options.disabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Stops using the file. The schema goes with it, since the file is what
|
|
||||||
// produced it; introspection repopulates if it's set to run automatically.
|
|
||||||
const removeSchemaFile = useCallback(async () => {
|
|
||||||
setError("");
|
|
||||||
setSchema(null);
|
|
||||||
await schemaFile.set(null);
|
|
||||||
await upsertIntrospection(null);
|
|
||||||
}, [schemaFile, upsertIntrospection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (introspection.data?.content == null || introspection.data.content === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseResult = tryParseIntrospectionToSchema(introspection.data.content);
|
|
||||||
if ("error" in parseResult) {
|
|
||||||
setError(parseResult.error);
|
|
||||||
} else {
|
|
||||||
setSchema(parseResult.schema);
|
|
||||||
}
|
|
||||||
}, [introspection.data?.content]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
schema,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
clear,
|
|
||||||
loadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useIntrospectionResult(request: HttpRequest) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["introspection", request.id],
|
|
||||||
queryFn: async () =>
|
|
||||||
rpc<GraphQlIntrospection | null>("models_get_graphql_introspection", {
|
|
||||||
requestId: request.id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCurrentGraphQLSchema(request: HttpRequest) {
|
|
||||||
const result = useIntrospectionResult(request);
|
|
||||||
return useMemo(() => {
|
|
||||||
if (result.data == null) return null;
|
|
||||||
if (result.data.content == null || result.data.content === "") return null;
|
|
||||||
const r = tryParseIntrospectionToSchema(result.data.content);
|
|
||||||
return "error" in r ? null : r.schema;
|
|
||||||
}, [result.data]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryParseIntrospectionToSchema(
|
|
||||||
content: string,
|
|
||||||
): { schema: GraphQLSchema } | { error: string } {
|
|
||||||
let parsedResponse: IntrospectionQuery;
|
|
||||||
try {
|
|
||||||
parsedResponse = JSON.parse(content).data;
|
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
} catch (e: any) {
|
|
||||||
return { error: String("message" in e ? e.message : e) };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return { schema: buildClientSchema(parsedResponse, {}) };
|
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
} catch (e: any) {
|
|
||||||
return { error: String("message" in e ? e.message : e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/** Subscribe to a backend event for the lifetime of the component. */
|
|
||||||
export function usePlatformEvent<T>(event: string, fn: (payload: T) => void) {
|
|
||||||
const handlerRef = useRef(fn);
|
|
||||||
useEffect(() => {
|
|
||||||
handlerRef.current = fn;
|
|
||||||
}, [fn]);
|
|
||||||
|
|
||||||
useEffect(() => platform.listen<T>(event, (payload) => handlerRef.current(payload)), [event]);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
export interface RecentFilter {
|
|
||||||
value: string;
|
|
||||||
pinned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_RECENT_FILTERS = 20;
|
|
||||||
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
|
|
||||||
const namespace = "global";
|
|
||||||
const fallback: RecentFilter[] = [];
|
|
||||||
|
|
||||||
export function useRecentFilters(filterStateKey: string | null) {
|
|
||||||
const { value, set } = useKeyValue<RecentFilter[]>({
|
|
||||||
key: kvKey(filterStateKey ?? "n/a"),
|
|
||||||
namespace,
|
|
||||||
fallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
const addFilter = useCallback(
|
|
||||||
async (rawValue: string) => {
|
|
||||||
const value = rawValue.trim();
|
|
||||||
if (filterStateKey == null || value === "") return;
|
|
||||||
await set((prev) => {
|
|
||||||
// Returning the same reference skips the write, so re-committing the
|
|
||||||
// expression already at the top (on every blur) costs nothing
|
|
||||||
if (prev[0]?.value === value) return prev;
|
|
||||||
const existing = prev.find((f) => f.value === value);
|
|
||||||
const rest = prev.filter((f) => f.value !== value);
|
|
||||||
return trim([{ value, pinned: existing?.pinned }, ...rest]);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[filterStateKey, set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeFilter = useCallback(
|
|
||||||
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
|
|
||||||
[set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const togglePin = useCallback(
|
|
||||||
async (value: string) =>
|
|
||||||
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
|
|
||||||
[set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearFilters = useCallback(async () => set([]), [set]);
|
|
||||||
|
|
||||||
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
|
|
||||||
function trim(filters: RecentFilter[]): RecentFilter[] {
|
|
||||||
const excess = filters.length - MAX_RECENT_FILTERS;
|
|
||||||
if (excess <= 0) return filters;
|
|
||||||
|
|
||||||
const evicted = new Set<number>();
|
|
||||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
|
||||||
if (!filters[i]?.pinned) evicted.add(i);
|
|
||||||
}
|
|
||||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
|
||||||
evicted.add(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filters.filter((_, i) => !evicted.has(i));
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import type { SseSummary } from "@yaakapp-internal/sse";
|
|
||||||
import { getResponseBodySseSummary } from "../lib/responseBody";
|
|
||||||
|
|
||||||
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
|
|
||||||
return useQuery<SseSummary>({
|
|
||||||
enabled: resultKeyPath != null,
|
|
||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
|
||||||
queryKey: [
|
|
||||||
"response-body-sse-summary",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
resultKeyPath,
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A URL for a stored response body, for the viewers that hand one to an element
|
|
||||||
* instead of reading the bytes themselves.
|
|
||||||
*
|
|
||||||
* Resolved once here rather than inside each viewer: the host may have to ask
|
|
||||||
* the backend where the body is, and a viewer that computes its source during
|
|
||||||
* render (the PDF one, deliberately) needs it settled before it mounts.
|
|
||||||
*
|
|
||||||
* Null data means the response has no stored body.
|
|
||||||
*/
|
|
||||||
export function useResponseBodyUrl(response: HttpResponse | null) {
|
|
||||||
const responseId = response?.id ?? null;
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
|
|
||||||
enabled: responseId != null,
|
|
||||||
// A response body is stored under the response's own id
|
|
||||||
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { createGlobalState } from "react-use";
|
|
||||||
import type { RecentFilter } from "./useRecentFilters";
|
|
||||||
import { useRecentFilters } from "./useRecentFilters";
|
|
||||||
|
|
||||||
/** What's typed in the filter box. `null` means the filter box is closed */
|
|
||||||
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
/** What's actually applied to the response. Only changes on an explicit apply */
|
|
||||||
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
export interface ResponseFilterApi {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Draft text in the filter box, or `null` when the box is closed */
|
|
||||||
filterText: string | null;
|
|
||||||
/** The expression currently filtering the response */
|
|
||||||
appliedFilter: string | null;
|
|
||||||
isSearching: boolean;
|
|
||||||
/** The box holds an expression that isn't the one currently applied */
|
|
||||||
isDirty: boolean;
|
|
||||||
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
|
|
||||||
filterUpdateKey: number;
|
|
||||||
setFilterText: (value: string | null) => void;
|
|
||||||
/** Apply the expression to the response, recording it if the filter accepts it */
|
|
||||||
applyFilter: (value: string) => void;
|
|
||||||
/** Like applyFilter, but also replaces what's shown in the filter box */
|
|
||||||
replaceFilter: (value: string) => void;
|
|
||||||
toggleSearch: () => void;
|
|
||||||
recentFilters: RecentFilter[];
|
|
||||||
removeRecentFilter: (value: string) => void;
|
|
||||||
togglePinRecentFilter: (value: string) => void;
|
|
||||||
clearRecentFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft/applied state and history for a response filter (JSONPath/XPath).
|
|
||||||
*
|
|
||||||
* History records at most one entry per apply gesture, and only after `runFilter`
|
|
||||||
* confirms the plugin accepts the expression — the plugin is the sole judge of
|
|
||||||
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
|
|
||||||
* deleted entries and a gesture can't record into another request's history.
|
|
||||||
*/
|
|
||||||
export function useResponseFilter({
|
|
||||||
stateKey,
|
|
||||||
runFilter,
|
|
||||||
}: {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Evaluate an expression, rejecting if the filter plugin reports an error */
|
|
||||||
runFilter: (filter: string) => Promise<unknown>;
|
|
||||||
}): ResponseFilterApi {
|
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
|
||||||
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
|
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
|
||||||
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
|
|
||||||
|
|
||||||
const setFilterText = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setFilterTextMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAppliedFilter = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setAppliedFilterMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
recentFilters,
|
|
||||||
addFilter,
|
|
||||||
removeFilter: removeRecentFilter,
|
|
||||||
togglePin: togglePinRecentFilter,
|
|
||||||
clearFilters: clearRecentFilters,
|
|
||||||
} = useRecentFilters(stateKey);
|
|
||||||
|
|
||||||
const applyFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setFilterText(value);
|
|
||||||
const applied = value.trim() === "" ? null : value.trim();
|
|
||||||
setAppliedFilter(applied);
|
|
||||||
if (applied == null) return;
|
|
||||||
runFilter(applied).then(
|
|
||||||
() => addFilter(applied),
|
|
||||||
() => {}, // Rejected by the filter plugin — don't record
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setFilterText, setAppliedFilter, runFilter, addFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
|
||||||
const replaceFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
applyFilter(value);
|
|
||||||
setFilterUpdateKey((k) => k + 1);
|
|
||||||
},
|
|
||||||
[applyFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
|
||||||
const toggleSearch = useCallback(() => {
|
|
||||||
if (isSearching) {
|
|
||||||
setFilterText(null);
|
|
||||||
setAppliedFilter(null);
|
|
||||||
} else {
|
|
||||||
setFilterText("");
|
|
||||||
}
|
|
||||||
}, [isSearching, setFilterText, setAppliedFilter]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
stateKey,
|
|
||||||
filterText,
|
|
||||||
appliedFilter,
|
|
||||||
isSearching,
|
|
||||||
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
|
|
||||||
filterUpdateKey,
|
|
||||||
setFilterText,
|
|
||||||
applyFilter,
|
|
||||||
replaceFilter,
|
|
||||||
toggleSearch,
|
|
||||||
recentFilters,
|
|
||||||
removeRecentFilter,
|
|
||||||
togglePinRecentFilter,
|
|
||||||
clearRecentFilters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
|
||||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
|
||||||
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
|
||||||
|
|
||||||
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
|
|
||||||
if (id == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await flushAllModelWrites();
|
|
||||||
|
|
||||||
return rpc("cmd_send_http_request", {
|
|
||||||
requestId: id,
|
|
||||||
environmentId: getActiveEnvironment()?.id,
|
|
||||||
cookieJarId: getActiveCookieJar()?.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSendAnyHttpRequest() {
|
|
||||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
|
||||||
mutationKey: ["send_any_request"],
|
|
||||||
mutationFn: sendAnyHttpRequestById,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
|
||||||
mutationKey: ["send_any_request"],
|
|
||||||
mutationFn: sendAnyHttpRequestById,
|
|
||||||
});
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
|
|
||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
|
|
||||||
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
|
|
||||||
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
|
|
||||||
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
|
|
||||||
|
|
||||||
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
|
|
||||||
{
|
|
||||||
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
|
|
||||||
detail: "ChatGPT (OpenAI)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
|
|
||||||
detail: "Responses (OpenAI)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: ANTHROPIC_RESULT_KEY_PATH,
|
|
||||||
detail: "Claude (Anthropic)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: GOOGLE_RESULT_KEY_PATH,
|
|
||||||
detail: "Gemini (Google)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
|
|
||||||
minMatch: 0,
|
|
||||||
options: sseSummaryResultKeyPathOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
|
|
||||||
const storedResultKeyPath = useKeyValue<string | null>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_summary_result_key_path", response.requestId],
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
const enabled = useKeyValue<boolean | null>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_summary_result_key_path_enabled", response.requestId],
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
|
|
||||||
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
|
|
||||||
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
|
|
||||||
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: isEnabled,
|
|
||||||
inferredResultKeyPath,
|
|
||||||
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
|
|
||||||
resultKeyPathInputValue: resultKeyPath ?? "",
|
|
||||||
setEnabled: enabled.set,
|
|
||||||
setResultKeyPath: storedResultKeyPath.set,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(response.url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
const pathname = url.pathname.toLowerCase();
|
|
||||||
|
|
||||||
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
|
|
||||||
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
|
|
||||||
return OPENAI_RESPONSES_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
|
|
||||||
return ANTHROPIC_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
hostname === "generativelanguage.googleapis.com" &&
|
|
||||||
pathname.includes(":streamgeneratecontent")
|
|
||||||
) {
|
|
||||||
return GOOGLE_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { useIsFullscreen } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export function useStoplightsVisible() {
|
|
||||||
const fullscreen = useIsFullscreen();
|
|
||||||
const stoplightsVisible = platform.osType() === "macos" && !fullscreen;
|
|
||||||
return stoplightsVisible;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function useWindowFocus() {
|
|
||||||
const [visible, setVisible] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return platform.window.onFocusChanged(setVisible);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return visible;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function initGit() {
|
|
||||||
let watchedDir: string | null = null;
|
|
||||||
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
|
|
||||||
|
|
||||||
const watchActiveWorkspace = () => {
|
|
||||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
|
|
||||||
if (syncDir === watchedDir) return;
|
|
||||||
|
|
||||||
void unwatch?.();
|
|
||||||
unwatch = null;
|
|
||||||
watchedDir = syncDir;
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, null);
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
|
|
||||||
|
|
||||||
if (syncDir == null) return;
|
|
||||||
|
|
||||||
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
|
|
||||||
if (syncDir !== watchedDir) return;
|
|
||||||
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, status);
|
|
||||||
|
|
||||||
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
|
|
||||||
for (const entry of status.entries) {
|
|
||||||
if (entry.modelId == null) continue;
|
|
||||||
statusByModelId[entry.modelId] = entry;
|
|
||||||
}
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watchActiveWorkspace();
|
|
||||||
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import type { DropdownItem } from "../components/core/Dropdown";
|
|
||||||
import { jotaiStore } from "./jotai";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A menu opened from somewhere that can't render React.
|
|
||||||
*
|
|
||||||
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
|
|
||||||
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
|
|
||||||
* synchronously, so it can only hand over a position and a list of items. Same shape as
|
|
||||||
* {@link showDialog}.
|
|
||||||
*/
|
|
||||||
export interface ContextMenuInstance {
|
|
||||||
id: string;
|
|
||||||
/** Where to open, in viewport coordinates */
|
|
||||||
triggerPosition: { x: number; y: number };
|
|
||||||
/** The trigger's box, when it has one, so placement can align to its edges */
|
|
||||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
|
||||||
/** The element it belongs to, so clicking that doesn't read as a click outside */
|
|
||||||
triggerEl?: HTMLElement | null;
|
|
||||||
items: DropdownItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
|
|
||||||
|
|
||||||
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
|
|
||||||
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the menu, or closes it if this one is already open.
|
|
||||||
*
|
|
||||||
* What a trigger wants: pressing it a second time should put the menu away. Same shape as
|
|
||||||
* {@link toggleDialog}.
|
|
||||||
*/
|
|
||||||
export function toggleContextMenu({ id, ...props }: ContextMenuInstance) {
|
|
||||||
if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) {
|
|
||||||
hideContextMenu(id);
|
|
||||||
} else {
|
|
||||||
showContextMenu({ id, ...props });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideContextMenu(id: string) {
|
|
||||||
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Common Accept-Language values.
|
|
||||||
export const acceptLanguages = [
|
|
||||||
"*",
|
|
||||||
"en",
|
|
||||||
"en-US",
|
|
||||||
"en-US,en;q=0.9",
|
|
||||||
"en-GB,en;q=0.9",
|
|
||||||
"de-DE,de;q=0.9,en;q=0.8",
|
|
||||||
"fr-FR,fr;q=0.9,en;q=0.8",
|
|
||||||
"es-ES,es;q=0.9,en;q=0.8",
|
|
||||||
"ru-RU,ru;q=0.9,en;q=0.8",
|
|
||||||
"uk-UA,uk;q=0.9,en;q=0.8",
|
|
||||||
"zh-CN,zh;q=0.9,en;q=0.8",
|
|
||||||
"ja-JP,ja;q=0.9,en;q=0.8",
|
|
||||||
];
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Common Cache-Control directives (request and response).
|
|
||||||
export const cacheControlDirectives = [
|
|
||||||
"no-cache",
|
|
||||||
"no-store",
|
|
||||||
"no-transform",
|
|
||||||
"max-age=0",
|
|
||||||
"max-age=3600",
|
|
||||||
"max-age=86400",
|
|
||||||
"s-maxage=3600",
|
|
||||||
"must-revalidate",
|
|
||||||
"proxy-revalidate",
|
|
||||||
"stale-while-revalidate=60",
|
|
||||||
"public",
|
|
||||||
"private",
|
|
||||||
"immutable",
|
|
||||||
];
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user