mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 05:02:16 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5919fae739 | ||
|
|
a9cccb21b8 | ||
|
|
1c0435e3ff | ||
|
|
a629a1fa79 | ||
|
|
dde8d61b4b | ||
|
|
08a64b6938 | ||
|
|
560c4667e4 | ||
|
|
21f775741a | ||
|
|
44a331929f | ||
|
|
7670ab007f | ||
|
|
be34dfe74a | ||
|
|
e4103f1a4a | ||
|
|
7f4eedd630 | ||
|
|
49659a3da9 |
No files matched your search
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "yaak",
|
||||
"interface": {
|
||||
"displayName": "Yaak"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./crates-cli/yaak-cli"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
+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-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)
|
||||
```
|
||||
|
||||
@@ -16,7 +16,7 @@ crates-cli/ # CLI crate (yaak-cli)
|
||||
|
||||
### 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-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
|
||||
4. Initialize managers in yaak-app's `.setup()` block
|
||||
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()`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `crates-tauri/yaak-app-client/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-client/src/models_ext.rs` - Database plugin and extension traits
|
||||
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
|
||||
- `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/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
|
||||
|
||||
- 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
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
|
||||
"name": "yaak",
|
||||
"description": "Plugins for Yaak, the desktop API client",
|
||||
"owner": {
|
||||
"name": "Yaak",
|
||||
"url": "https://yaak.app"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": "./crates-cli/yaak-cli",
|
||||
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
|
||||
"category": "development",
|
||||
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`
|
||||
5. Extract useful details:
|
||||
- Feedback URLs (`feedback.yaak.app`)
|
||||
- Contributor GitHub handles from `author.login`
|
||||
- Plugin install links or other notable context
|
||||
6. Format notes using Yaak style:
|
||||
- Changelog badge at top
|
||||
- Bulleted items with PR links where available
|
||||
- Contributor handles for external PRs
|
||||
- Feedback links where available
|
||||
- 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.
|
||||
- Keep a blank line before and after the code fence.
|
||||
- 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`.
|
||||
- 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
-3
@@ -1,8 +1,7 @@
|
||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
|
||||
**/bindings/* linguist-generated=true
|
||||
crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
|
||||
@@ -1,55 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Build tooling majors are opted into, not queued. These never reach a
|
||||
# user, so the cost of being a release behind is low and the cost of a
|
||||
# surprise is a broken build. Note this also suppresses security PRs for
|
||||
# these, so Dependabot alerts are the backstop.
|
||||
- dependency-name: typescript
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: vitest
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vite-plugin-*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# 0.8 dropped stateExtensions and updateSchema from the package root and
|
||||
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
|
||||
# that editor moves to the jsonSchema helper.
|
||||
- dependency-name: codemirror-json-schema
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
|
||||
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
|
||||
- dependency-name: oxc_resolver
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
@@ -4,16 +4,13 @@
|
||||
|
||||
## 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.
|
||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||
- [ ] 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.
|
||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
- [ ] I tested this change locally.
|
||||
- [ ] I added or updated tests, or tests are not reasonable for this change.
|
||||
- [ ] I added screenshots or recordings, or this change does not affect the UI.
|
||||
- [ ] I added or updated tests when reasonable.
|
||||
|
||||
Explicit permission feedback item (required if not a bug fix):
|
||||
Approved feedback item (required if not a bug fix or small-scope improvement):
|
||||
|
||||
<!-- https://yaak.app/feedback/... -->
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
@@ -30,34 +30,4 @@ jobs:
|
||||
- name: Run JS Tests
|
||||
run: vp test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --features yaak-app-client/wry
|
||||
- name: OpenAPI import round-trip
|
||||
run: |
|
||||
cargo build -p yaak-cli
|
||||
node plugins/importer-openapi/tests/roundtrip.mjs
|
||||
|
||||
test-windows:
|
||||
name: Rust Tests (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: vp install
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: x64
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
||||
run: cargo test --all
|
||||
@@ -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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish to Flathub
|
||||
required: true
|
||||
type: string
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-flathub:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Update Flathub manifest
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for stable releases (skip betas/pre-releases)
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Checkout app repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -35,7 +31,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "22"
|
||||
|
||||
- name: Install source generators
|
||||
run: |
|
||||
@@ -43,7 +39,7 @@ jobs:
|
||||
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
|
||||
|
||||
- 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
|
||||
working-directory: flathub-repo
|
||||
@@ -52,5 +48,5 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
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
|
||||
@@ -15,7 +15,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Publish @yaakapp/api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -2,24 +2,9 @@ name: Release App Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
# The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and
|
||||
# keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
proxy_dry_run:
|
||||
description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)"
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
proxy_version:
|
||||
description: "Version to stamp into the dry-run build"
|
||||
type: string
|
||||
required: true
|
||||
default: "0.0.1-dryrun"
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -29,62 +14,38 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- 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"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-apple-darwin"
|
||||
- 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"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
args: ""
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
args: ""
|
||||
yaak_arch: "arm64"
|
||||
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: ""
|
||||
- platform: "windows-latest"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
args: ""
|
||||
yaak_arch: "x64"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
# Windows ARM64
|
||||
- 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"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 360
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
@@ -105,30 +66,11 @@ jobs:
|
||||
shared-key: ci
|
||||
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)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 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"
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
@@ -156,7 +98,7 @@ jobs:
|
||||
- name: Run JS Tests
|
||||
run: vp test
|
||||
- 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
|
||||
run: npm run replace-version
|
||||
@@ -183,11 +125,10 @@ jobs:
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# 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-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.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || 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
|
||||
|
||||
- name: Build and upload Tauri bundles
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
@@ -209,49 +150,12 @@ jobs:
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
# Also retries builds; upload retries alone cannot clean up starter assets.
|
||||
retryAttempts: 3
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
releaseBody: "<!-- generated-by-yaak-releases -->"
|
||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
|
||||
- name: Snapshot Tauri bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_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" .
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -267,215 +171,12 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
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 '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Pop-Location
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
foreach ($asset in @($dest, $destSig)) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
if ($attempt -eq 4) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
- name: Snapshot additional release bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Retain release bundles for recovery
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing
|
||||
# with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly.
|
||||
#
|
||||
# On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's
|
||||
# download links pick Yaak's assets by suffix and architecture, and its release audit expects
|
||||
# Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release
|
||||
# until the website expects it there. The dry run (workflow_dispatch) runs regardless, and
|
||||
# only keeps workflow artifacts.
|
||||
build-proxy:
|
||||
if: >-
|
||||
github.repository == 'mountain-loop/yaak' && (
|
||||
(github.event_name == 'push' && vars.RELEASE_PROXY == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run)
|
||||
)
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build Yaak Proxy
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above).
|
||||
yaak_arch: "arm64"
|
||||
lipo_arch: "arm64"
|
||||
asset_arch: "aarch64"
|
||||
- target: "x86_64-apple-darwin" # for Intel-based Macs.
|
||||
yaak_arch: "x64"
|
||||
lipo_arch: "x86_64"
|
||||
asset_arch: "x64"
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# The tag, as Yaak's version is taken, or the dry run's own
|
||||
PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# A cache of its own, so the proxy's builds never change what Yaak's jobs restore
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release-proxy
|
||||
key: ${{ matrix.target }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: vp install
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version -- proxy
|
||||
env:
|
||||
YAAK_VERSION: ${{ env.PROXY_VERSION }}
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
|
||||
security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
rm "$RUNNER_TEMP/certificate.p12"
|
||||
# Lets codesign use the key unattended; without it the first signature can wait on a
|
||||
# prompt nobody answers
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
|
||||
# Builds the helper for the target and signs it and protoc before bundling (the bundle
|
||||
# config's before-build step), then signs, notarizes and staples the app and makes the
|
||||
# updater archive and its signature.
|
||||
- name: Build, sign and notarize
|
||||
run: >-
|
||||
npm run proxy:bundle --
|
||||
--target ${{ matrix.target }}
|
||||
--bundles dmg
|
||||
--config crates-tauri/yaak-app-proxy/tauri.release.conf.json
|
||||
env:
|
||||
CI: true
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# The helper only answers an app signed by this team
|
||||
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: Verify the bundle
|
||||
run: >-
|
||||
node scripts/verify-proxy-bundle.mjs
|
||||
"target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app"
|
||||
--signed
|
||||
--version "$PROXY_VERSION"
|
||||
--arch ${{ matrix.lipo_arch }}
|
||||
--team "$YAAK_PROXY_TEAM_ID"
|
||||
env:
|
||||
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets.
|
||||
# The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs).
|
||||
- name: Collect the release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bundle="target/${{ matrix.target }}/release/bundle"
|
||||
version="${PROXY_VERSION#v}"
|
||||
out="$RUNNER_TEMP/yaak-proxy-release"
|
||||
mkdir -p "$out"
|
||||
dmgs=("$bundle"/dmg/*.dmg)
|
||||
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
|
||||
echo "Expected one .dmg in $bundle/dmg"; exit 1
|
||||
fi
|
||||
cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg"
|
||||
cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz"
|
||||
cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig"
|
||||
(cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt)
|
||||
ls -l "$out"
|
||||
|
||||
- name: Keep the bundles as workflow artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-proxy-release/
|
||||
retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Yaak's jobs create the draft release; this waits for it rather than making its own,
|
||||
# which could race theirs into two drafts.
|
||||
- name: Upload to the release
|
||||
if: env.DRY_RUN != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 120); do
|
||||
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi
|
||||
if [ "$attempt" -eq 120 ]; then
|
||||
echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
done
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
@@ -2,7 +2,7 @@ name: Release CLI to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
tags: [yaak-cli-*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -15,7 +15,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
prepare-vendored-assets:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Prepare vendored plugin assets
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,8 +45,8 @@ jobs:
|
||||
with:
|
||||
name: vendored-assets
|
||||
path: |
|
||||
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
|
||||
crates-tauri/yaak-app-client/vendored/plugins
|
||||
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
|
||||
crates-tauri/yaak-app/vendored/plugins
|
||||
if-no-files-found: error
|
||||
|
||||
build-binaries:
|
||||
@@ -108,7 +107,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vendored-assets
|
||||
path: crates-tauri/yaak-app-client/vendored
|
||||
path: crates-tauri/yaak-app/vendored
|
||||
|
||||
- name: Set CLI build version
|
||||
shell: bash
|
||||
@@ -119,7 +118,7 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "Building yaak version: $VERSION"
|
||||
@@ -176,7 +175,7 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
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
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
|
||||
+1
-6
@@ -39,8 +39,7 @@ codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
||||
crates-tauri/yaak-app/tauri.worktree.conf.json
|
||||
|
||||
# Tauri auto-generated permission files
|
||||
**/permissions/autogenerated
|
||||
@@ -58,7 +57,3 @@ flatpak/node-sources.json
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Headless screenshots taken while working
|
||||
.shots/
|
||||
@@ -0,0 +1,2 @@
|
||||
**/bindings/**
|
||||
crates/yaak-templates/pkg/**
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts",
|
||||
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
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
|
||||
+2
-1
@@ -3,12 +3,13 @@
|
||||
Yaak accepts community pull requests for:
|
||||
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Generated
+1408
-3014
File diff suppressed because it is too large.
Load diff
+7
-61
@@ -2,50 +2,29 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"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)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
"crates/yaak-crypto",
|
||||
"crates/yaak-filter",
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-mcp",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-query",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-wasm",
|
||||
"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",
|
||||
"crates-server/yaak-playground",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
"crates-tauri/yaak-app-client",
|
||||
"crates-tauri/yaak-app-proxy",
|
||||
"crates-tauri/yaak-app",
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-updater",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -53,76 +32,43 @@ chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
|
||||
# rest of the old default set and stay on native-tls.
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"native-tls",
|
||||
] }
|
||||
reqwest = "0.12.20"
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.7.1"
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tauri = { version = "2.11.1", default-features = false, features = [
|
||||
"common-controls-v6",
|
||||
"compression",
|
||||
"dynamic-acl",
|
||||
] }
|
||||
tauri-plugin = "2.6.1"
|
||||
tauri-plugin-dialog = { version = "2.7.1", default-features = false }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri = "2.9.5"
|
||||
tauri-plugin = "2.5.2"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-shell = "2.3.3"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.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
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-filter = { path = "crates/yaak-filter" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-mcp = { path = "crates/yaak-mcp" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-query = { path = "crates/yaak-query" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
yaak-sync = { path = "crates/yaak-sync" }
|
||||
yaak-templates = { path = "crates/yaak-templates" }
|
||||
yaak-tls = { path = "crates/yaak-tls" }
|
||||
yaak-ws = { path = "crates/yaak-ws" }
|
||||
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
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-updater = { path = "crates-tauri/yaak-updater" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
[profile.release]
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
New migrations can be created from the `src-tauri/` directory:
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-playground
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
CMD ["yaak-playground"]
|
||||
@@ -1,49 +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
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=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">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
@@ -17,18 +17,14 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- 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/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></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 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>
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
|
||||
|
||||
## Features
|
||||
|
||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
||||
@@ -61,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
## Contribution Policy
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Community PRs are currently limited to bug fixes.
|
||||
> 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.
|
||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
||||
> 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.
|
||||
|
||||
## Useful Resources
|
||||
@@ -72,9 +68,3 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
## License
|
||||
|
||||
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
|
||||
|
||||
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { FunctionComponent, ReactElement } from "react";
|
||||
import { beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
import { syncWorkspace } from "./commands";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
calculateSync: vi.fn(),
|
||||
applySync: vi.fn(),
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
hideDialog: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/sync", () => mocks);
|
||||
vi.mock("@yaakapp-internal/models", () => ({
|
||||
createWorkspaceModel: vi.fn(),
|
||||
modelTypeLabel: () => "Request",
|
||||
}));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Banner: "div",
|
||||
InlineCode: "code",
|
||||
Table: "table",
|
||||
TableBody: "tbody",
|
||||
TableCell: "td",
|
||||
TableHead: "thead",
|
||||
TableHeaderCell: "th",
|
||||
TableRow: "tr",
|
||||
TruncatedWideTableCell: "td",
|
||||
}));
|
||||
vi.mock("../components/core/Button", () => ({ Button: "button" }));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
|
||||
vi.mock("../lib/dialog", () => mocks);
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
|
||||
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
|
||||
vi.mock("../lib/toast", () => mocks);
|
||||
|
||||
const staleToast = expect.objectContaining({
|
||||
message: "Files changed while syncing. Review the changes again.",
|
||||
});
|
||||
|
||||
const params = { workspaceId: "wk_test", syncDir: "/sync" };
|
||||
const deletion = {
|
||||
type: "dbDelete",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbDelete" }>;
|
||||
const update = {
|
||||
type: "dbUpdate",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbUpdate" }>;
|
||||
|
||||
async function submit(dialog: DialogInstance) {
|
||||
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
|
||||
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
|
||||
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
|
||||
}>;
|
||||
await form.props.onSubmit({ preventDefault: vi.fn() });
|
||||
}
|
||||
|
||||
function latestDialog() {
|
||||
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
|
||||
if (dialog == null) throw new Error("Expected a sync dialog");
|
||||
return dialog;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.applySync.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("requires another confirmation for a changed plan", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
const original = latestDialog();
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(original);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.hideDialog).not.toHaveBeenCalled();
|
||||
await submit(latestDialog());
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("applies an unchanged reviewed plan and closes the dialog", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("forced sync retries the current plan without requesting another confirmation", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("outbound-only changes clear the old dialog and sync automatically", async () => {
|
||||
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("stops retrying a plan that keeps going stale", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
mocks.applySync.mockResolvedValue(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.showToast).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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,99 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import {
|
||||
Banner,
|
||||
type EditorHost,
|
||||
EditorHostProvider,
|
||||
EmptyStateText,
|
||||
type HostBodyViewerArgs,
|
||||
type HttpBodyLoader,
|
||||
LoadingIcon,
|
||||
type StoredValue,
|
||||
type StoredValueNamespace,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { lazy, type ReactNode, Suspense } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { Input } from "./core/Input";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
function useClientStoredValue<T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
namespace: StoredValueNamespace = "global",
|
||||
) {
|
||||
// A null key is never written by the shared components, so any stable key will do
|
||||
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
|
||||
return [value ?? fallback, set] as const;
|
||||
}
|
||||
|
||||
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
|
||||
if (!mimeType?.match(/pdf/i)) return null;
|
||||
return source === "response" ? (
|
||||
<PdfResponseBody body={body} complete={complete} />
|
||||
) : (
|
||||
<PdfRequestBody body={body} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the whole response, then loads the PDF from its file */
|
||||
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
|
||||
const bodyUrl = body.useUrl(complete);
|
||||
|
||||
if (!complete || bodyUrl.isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
return <PdfViewer bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
|
||||
const bytes = body.useBytes();
|
||||
if (bytes.data == null) return null;
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const clientEditorHost: EditorHost = {
|
||||
Editor,
|
||||
Input,
|
||||
SelectFile,
|
||||
showDialog,
|
||||
showPrompt,
|
||||
basename: (path) => platform.files.basename(path),
|
||||
useStoredValue: useClientStoredValue,
|
||||
useDetailsBannerStoredValue,
|
||||
ErrorBoundary,
|
||||
TextViewer,
|
||||
MultipartViewer,
|
||||
renderBodyViewer,
|
||||
openUrl: (url) => platform.openUrl(url),
|
||||
copy: (text) => copyToClipboard(text, { disableToast: true }),
|
||||
};
|
||||
|
||||
/** Gives the shared editing components the client's templated editor, dialogs and storage */
|
||||
export function ClientEditorHost({ children }: { children: ReactNode }) {
|
||||
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -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,732 +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,
|
||||
KeyValueRows,
|
||||
} 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 } 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,22 +0,0 @@
|
||||
import {
|
||||
CopyIconButton as BaseCopyIconButton,
|
||||
type CopyIconButtonProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
|
||||
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, { disableToast: true });
|
||||
}
|
||||
|
||||
function showCopyFailed() {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { EmptyStateText } from "@yaakapp-internal/ui";
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||
{children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function showErrorDialog(error: unknown) {
|
||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
useEffect(() => {
|
||||
throw new Error("test error");
|
||||
});
|
||||
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { hideToastById, showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Input } from "./core/Input";
|
||||
|
||||
interface Props {
|
||||
feature: FeedbackFeature;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackToast({ feature, onDone }: Props) {
|
||||
const [text, setText] = useState<string>("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const sentRef = useRef(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDone();
|
||||
hideToastById(`feature-feedback-${feature}`);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmedText = text.trim();
|
||||
if (sentRef.current || trimmedText.length === 0) return;
|
||||
|
||||
sentRef.current = true;
|
||||
setSent(true);
|
||||
onDone();
|
||||
|
||||
// Fire-and-forget; failures are intentionally ignored
|
||||
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: 3000,
|
||||
color: "success",
|
||||
message: "Thanks for the feedback!",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
||||
<div className="h-20">
|
||||
<Input
|
||||
size="xs"
|
||||
// The editor forces its mono font on the scroller, so the override
|
||||
// has to target it directly
|
||||
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
||||
label="Feedback"
|
||||
hideLabel
|
||||
stateKey={null}
|
||||
multiLine
|
||||
fullHeight
|
||||
placeholder="Your thoughts..."
|
||||
onChange={setText}
|
||||
/>
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="end">
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={sent || text.trim().length === 0}
|
||||
onClick={handleSend}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
jsonSchemaLinter,
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
request: GrpcRequest;
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
request,
|
||||
protoFiles,
|
||||
...extraEditorProps
|
||||
}: Props) {
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
<>
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
<>
|
||||
Failed to find method <InlineCode>{request.method}</InlineCode> for{" "}
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
<VStack space={4}>
|
||||
<p>
|
||||
For service <InlineCode>{request.service}</InlineCode> and method{" "}
|
||||
<InlineCode>{request.method}</InlineCode>
|
||||
</p>
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
linter(jsonSchemaLinter(), {
|
||||
delay: 200,
|
||||
needsRefresh: handleRefresh,
|
||||
}),
|
||||
jsoncLanguage.data.of({
|
||||
autocomplete: jsonCompletion(),
|
||||
}),
|
||||
stateExtensions({}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
className: "h-[30rem]",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<Editor
|
||||
setRef={handleInitEditorViewRef}
|
||||
language="json"
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
defaultValue={request.message}
|
||||
heightMode="auto"
|
||||
placeholder="..."
|
||||
extraExtensions={extraExtensions}
|
||||
actions={actions}
|
||||
stateKey={`grpc_message.${request.id}`}
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrpcProtoSelectionDialog(props: Props) {
|
||||
const request = useActiveRequest();
|
||||
if (request?.model !== "grpc_request") return null;
|
||||
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({
|
||||
request,
|
||||
onDone,
|
||||
}: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
const services = grpc.reflect.data;
|
||||
const serverReflection = protoFiles.length === 0 && services != null;
|
||||
let reflectError = grpc.reflect.error ?? null;
|
||||
const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
|
||||
|
||||
if (reflectionUnimplemented) {
|
||||
reflectError = null;
|
||||
}
|
||||
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={5} className="pb-4">
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
|
||||
</h1>
|
||||
<p>{reflectError.trim()}</p>
|
||||
</Banner>
|
||||
)}
|
||||
{!serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Found services{" "}
|
||||
{services?.slice(0, 5).map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{services?.length > 5 && pluralizeCount("other", services?.length - 5)}
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
{serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Server reflection found services
|
||||
{services?.map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
|
||||
files.
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<PathList
|
||||
items={protoFiles.map((path) => ({
|
||||
path,
|
||||
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
|
||||
}))}
|
||||
onRemove={async (path) => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
addLabel="Add proto files or an import folder"
|
||||
addItems={[
|
||||
{
|
||||
label: "Proto files",
|
||||
icon: "file_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Import folder",
|
||||
icon: "folder_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
|
||||
Server Reflection
|
||||
</Link>{" "}
|
||||
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: "Refresh Schema",
|
||||
isLoading: grpc.reflect.isFetching,
|
||||
disabled: grpc.reflect.isFetching,
|
||||
onClick: () => grpc.reflect.refetch(),
|
||||
},
|
||||
{ label: "Done", color: "primary", onClick: onDone },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { HeadersEditor } from "@yaakapp-internal/ui";
|
||||
@@ -1,466 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
CountBadge,
|
||||
type EditablePair,
|
||||
type ExtraPaneTab,
|
||||
HttpRequestPaneView,
|
||||
InlineCode,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { httpRequestToView } from "../lib/httpViews";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
getContentTypeFromHeaders,
|
||||
} from "../lib/model_util";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab } from "./core/Tabs/Tabs";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
const GraphQLEditor = lazy(() =>
|
||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
activeRequest: HttpRequest;
|
||||
}
|
||||
|
||||
const TAB_BODY = "body";
|
||||
const TAB_PARAMS = "params";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const numSettingsOverrides = countOverriddenSettings(activeRequest);
|
||||
|
||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||
useRequestEditorEvent(
|
||||
"request_pane.focus_tab",
|
||||
() => {
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
|
||||
if (activeRequest == null) {
|
||||
console.error("Failed to get active request to update", activeRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
|
||||
|
||||
if (contentType != null) {
|
||||
headers.push({
|
||||
name: "Content-Type",
|
||||
value: contentType,
|
||||
enabled: true,
|
||||
id: generateId(),
|
||||
});
|
||||
}
|
||||
await patchModel(activeRequest, { ...patch, headers });
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("http_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
|
||||
) {
|
||||
numParams = Array.isArray(activeRequest.body?.form)
|
||||
? activeRequest.body.form.filter((p) => p.name).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const bodyTab = useMemo<TabItem>(
|
||||
() =>
|
||||
({
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
items: [
|
||||
{ type: "separator", label: "Form Data" },
|
||||
{ label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
|
||||
{ label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
|
||||
{ type: "separator", label: "Text Content" },
|
||||
{ label: "GraphQL", value: BODY_TYPE_GRAPHQL },
|
||||
{ label: "JSON", value: BODY_TYPE_JSON },
|
||||
{ label: "XML", value: BODY_TYPE_XML },
|
||||
{
|
||||
label: "Other",
|
||||
value: BODY_TYPE_OTHER,
|
||||
shortLabel: nameOfContentTypeOr(contentType, "Other"),
|
||||
},
|
||||
{ type: "separator", label: "Other" },
|
||||
{ label: "Binary File", value: BODY_TYPE_BINARY },
|
||||
{ label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
|
||||
],
|
||||
onChange: async (bodyType) => {
|
||||
if (bodyType === activeRequest.bodyType) return;
|
||||
|
||||
const showMethodToast = (newMethod: string) => {
|
||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
||||
showToast({
|
||||
id: "switched-method",
|
||||
message: (
|
||||
<>
|
||||
Request method switched to <InlineCode>POST</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = {
|
||||
bodyType,
|
||||
body: convertRequestBody({
|
||||
body: activeRequest.body,
|
||||
fromBodyType: activeRequest.bodyType,
|
||||
toBodyType: bodyType,
|
||||
}),
|
||||
};
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
} else if (
|
||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
||||
bodyType === BODY_TYPE_JSON ||
|
||||
bodyType === BODY_TYPE_OTHER ||
|
||||
bodyType === BODY_TYPE_XML
|
||||
) {
|
||||
const isDefaultishRequest =
|
||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
||||
activeRequest.method.toLowerCase() === "get";
|
||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
||||
if (isDefaultishRequest || requiresPost) {
|
||||
patch.method = "POST";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
|
||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
||||
patch.method = "POST";
|
||||
newContentType = "application/json";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
|
||||
if (newContentType !== undefined) {
|
||||
await handleContentTypeChange(newContentType, patch);
|
||||
} else {
|
||||
await patchModel(activeRequest, patch);
|
||||
}
|
||||
},
|
||||
},
|
||||
}) satisfies TabItem,
|
||||
[activeRequest, contentType, handleContentTypeChange, numParams],
|
||||
);
|
||||
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent, text: string) => {
|
||||
if (looksLikeCurl(text)) {
|
||||
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
|
||||
} else {
|
||||
const patch = prepareImportQuerystring(text);
|
||||
if (patch != null) {
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
setTimeout(() => {
|
||||
forceUrlRefresh();
|
||||
forceParamsRefresh();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
[activeRequest.id, sendRequest],
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleJsonOrBodyTextChange = useCallback(
|
||||
(text: string, bodyType: string | null) => {
|
||||
// JSON writes at once, as its editor always has; other text debounces
|
||||
if (bodyType === BODY_TYPE_JSON) {
|
||||
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
|
||||
}
|
||||
return handleBodyTextChange(text);
|
||||
},
|
||||
[activeRequest, handleBodyTextChange],
|
||||
);
|
||||
|
||||
const handleSendJsonCommentsChange = useCallback(
|
||||
(sendJsonComments: boolean) => {
|
||||
const newBody = { ...activeRequest.body };
|
||||
if (sendJsonComments) {
|
||||
newBody.sendJsonComments = true;
|
||||
} else {
|
||||
delete newBody.sendJsonComments;
|
||||
}
|
||||
fireAndForget(patchModel(activeRequest, { body: newBody }));
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
|
||||
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() => [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
content: <ModelSettingsEditor model={activeRequest} />,
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
content: (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
|
||||
);
|
||||
|
||||
const renderBody = useCallback(
|
||||
(sharedEditor: ReactNode) => (
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : (
|
||||
sharedEditor
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
),
|
||||
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
|
||||
);
|
||||
|
||||
const renderUrlParameters = useCallback(
|
||||
(pairs: EditablePair[]) => (
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={pairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
),
|
||||
[activeRequest, forceUpdateKey, urlParametersKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpRequestPaneView
|
||||
editable
|
||||
style={style}
|
||||
className={className}
|
||||
fullHeight={fullHeight}
|
||||
request={requestView}
|
||||
tabsRef={tabsRef}
|
||||
tabsStorageKey={TABS_STORAGE_KEY}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
urlBarKey={forceUpdateKey + urlKey}
|
||||
urlForceUpdateKey={updateKey}
|
||||
urlAutocomplete={autocomplete}
|
||||
onUrlChange={handleUrlChange}
|
||||
onUrlPasteOverwrite={handlePaste}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
isSending={activeResponse != null && activeResponse.state !== "closed"}
|
||||
sendHotkeyAction="request.send"
|
||||
focusHotkeyAction="url_bar.focus"
|
||||
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
|
||||
bodyTab={bodyTab}
|
||||
renderBody={renderBody}
|
||||
onBodyChange={handleBodyChange}
|
||||
onBodyTextChange={handleJsonOrBodyTextChange}
|
||||
onSendJsonCommentsChange={handleSendJsonCommentsChange}
|
||||
jsonNoticeStorageKey={activeRequest.workspaceId}
|
||||
urlParameterPairs={urlParameterPairs}
|
||||
renderUrlParameters={renderUrlParameters}
|
||||
headersTabs={headersTab}
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
authTabs={authTab}
|
||||
authContent={<HttpAuthenticationEditor model={activeRequest} />}
|
||||
extraTabs={extraTabs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||
const language = languageFromContentType(contentType);
|
||||
if (language === "markdown") {
|
||||
return "Markdown";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import {
|
||||
CountBadge,
|
||||
type ExtraPaneTab,
|
||||
type HostBodyViewerArgs,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
type ResponseTextViewerExtras,
|
||||
type TextFilterApi,
|
||||
Tooltip,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRecentFilters } from "../hooks/useRecentFilters";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { httpResponseToView } from "../lib/httpViews";
|
||||
import { getCookieCounts } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
|
||||
interface RedirectDropWarning {
|
||||
droppedBodyCount: number;
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
/** The shared response pane, bound to the pinned response of the active request */
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
const redirectDropWarning = useMemo(
|
||||
() => getRedirectDropWarning(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
const shouldShowRedirectDropWarning =
|
||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
||||
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const body = useResponseBodyLoader(activeResponse ?? null);
|
||||
const requestBody = useRequestBodyLoader(activeResponse ?? null);
|
||||
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
|
||||
|
||||
const responseView = useMemo(
|
||||
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
|
||||
[activeResponse, responseEvents.data],
|
||||
);
|
||||
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() =>
|
||||
activeResponse == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
content: (
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
|
||||
);
|
||||
|
||||
const renderBodyViewer = useCallback(
|
||||
({ mimeType, viewMode }: HostBodyViewerArgs) =>
|
||||
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : null,
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpResponsePaneView
|
||||
style={style}
|
||||
className={className}
|
||||
response={responseView}
|
||||
body={body}
|
||||
requestBody={requestBody}
|
||||
emptyState={
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
}
|
||||
viewMode={viewMode === "raw" ? "raw" : "pretty"}
|
||||
onViewModeChange={setViewMode}
|
||||
activeTabKey={activeRequestId}
|
||||
onSaveBody={saveResponse.mutate}
|
||||
onCopyBody={copyResponse.mutate}
|
||||
onCancel={cancel.mutate}
|
||||
cookieCounts={cookieCounts}
|
||||
extraTabs={extraTabs}
|
||||
textViewer={textViewer}
|
||||
renderBodyViewer={renderBodyViewer}
|
||||
renderCopyButton={renderCopyButton}
|
||||
statusLineCenter={
|
||||
shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">Redirect changed this request</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's additions to the response text viewer: filter history, save and copy */
|
||||
function useResponseTextViewerExtras(
|
||||
response: HttpResponse | null,
|
||||
): ResponseTextViewerExtras | undefined {
|
||||
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
|
||||
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
|
||||
useRecentFilters(filterStateKey);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const filterLeftSlot = useCallback(
|
||||
(filter: TextFilterApi) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
|
||||
if (response == null) return undefined;
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
return {
|
||||
filterStateKey,
|
||||
onFilterApplied: addFilter,
|
||||
filterLeftSlot,
|
||||
footerActions: [
|
||||
<IconButton
|
||||
key="save"
|
||||
size="sm"
|
||||
icon="save"
|
||||
title="Save response to file"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => saveResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
title="Copy response body"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => copyResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderCopyButton(getText: () => Promise<string | null>) {
|
||||
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
if (events == null || events.length === 0) return null;
|
||||
|
||||
let droppedBodyCount = 0;
|
||||
const droppedHeaders = new Set<string>();
|
||||
for (const e of events) {
|
||||
const event = e.event;
|
||||
if (event.type !== "redirect") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.dropped_body) {
|
||||
droppedBodyCount += 1;
|
||||
}
|
||||
for (const headerName of event.dropped_headers ?? []) {
|
||||
pushHeaderName(droppedHeaders, headerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
droppedBodyCount,
|
||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.add(headerName);
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||
return "Dropped body and headers";
|
||||
}
|
||||
if (warning.droppedBodyCount > 0) {
|
||||
return "Dropped body";
|
||||
}
|
||||
return "Dropped headers";
|
||||
}
|
||||
@@ -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,769 +0,0 @@
|
||||
import {
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import {
|
||||
Banner,
|
||||
FormattedError,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
SegmentedControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import {
|
||||
ImportSourceList,
|
||||
type ImportSourceDetection,
|
||||
type ImportSourcePath,
|
||||
InlineButton,
|
||||
toSourcePath,
|
||||
} from "./ImportSourceList";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
|
||||
detectSource: (source: ImportSourcePath) => Promise<string>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
||||
* patching it in with effects after the first paint.
|
||||
*/
|
||||
export function ImportDataDialog(props: Props) {
|
||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
||||
const { currentWorkspace, listSources } = props;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
||||
load
|
||||
.then((sources) => {
|
||||
if (!cancelled) setInitialSources(sources);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInitialSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentWorkspace, listSources]);
|
||||
|
||||
if (initialSources == null) return null;
|
||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||
}
|
||||
|
||||
function LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
planSources,
|
||||
detectSource,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
initialSources,
|
||||
}: Props & { initialSources: ImportSource[] }) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [sourceError, setSourceError] = useState<unknown>(null);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
|
||||
const initialPaths = useMemo(
|
||||
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
|
||||
[initialSources],
|
||||
);
|
||||
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
|
||||
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
|
||||
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
|
||||
const detect = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setDetections((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
|
||||
}));
|
||||
for (const source of added) {
|
||||
const settle = (detection: ImportSourceDetection) =>
|
||||
setDetections((prev) =>
|
||||
source.path in prev ? { ...prev, [source.path]: detection } : prev,
|
||||
);
|
||||
detectSource(source).then(
|
||||
(importer) => settle({ status: "ok", importer }),
|
||||
(err) => settle({ status: "error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[detectSource],
|
||||
);
|
||||
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
|
||||
useEffect(() => {
|
||||
detect(initialPaths);
|
||||
}, [detect, initialPaths]);
|
||||
|
||||
const addSources = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setSourceError(null);
|
||||
const fresh = added.filter(({ path }) => {
|
||||
if (knownPaths.current.has(path)) return false;
|
||||
knownPaths.current.add(path);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setSources((existing) => [...existing, ...fresh]);
|
||||
detect(fresh);
|
||||
},
|
||||
[detect],
|
||||
);
|
||||
const removeSource = (path: string) => {
|
||||
setSourceError(null);
|
||||
knownPaths.current.delete(path);
|
||||
setSources((current) => current.filter((source) => source.path !== path));
|
||||
setDetections(({ [path]: _removed, ...rest }) => rest);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sources.length === 0) {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
sources.map(({ path, kind }) =>
|
||||
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
|
||||
),
|
||||
)
|
||||
.then((found) => {
|
||||
if (!cancelled) setOriginSources(found.flat());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOriginSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace these sources are linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
||||
}, [originSources, workspaces]);
|
||||
|
||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
||||
// explicit choice always wins.
|
||||
const destinationKind =
|
||||
destinationChoice ??
|
||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
||||
|
||||
const destinationWorkspaceId =
|
||||
destinationKind === "current"
|
||||
? (currentWorkspace?.id ?? null)
|
||||
: destinationKind === "other"
|
||||
? otherWorkspaceId
|
||||
: null;
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
setSourceError(null);
|
||||
try {
|
||||
const nextPlan = await planSources(sources, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
setSourceError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit({ ...plan, items });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}, [items]);
|
||||
|
||||
if (plan != null) {
|
||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
||||
const footerNote =
|
||||
unchanged.length > 0
|
||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
||||
: "";
|
||||
const changeCount = items.filter((item) => {
|
||||
if (disabledIds.has(item.modelId)) return false;
|
||||
if (item.action === "conflict") return item.resolution === "take_source";
|
||||
if (item.action === "unchanged") return false;
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [
|
||||
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
|
||||
names.join(", "),
|
||||
];
|
||||
}
|
||||
const { workspaceId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return ["Destination", name];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
planDestination.type === "existing_workspace"
|
||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||
: null;
|
||||
const destinations = existing ? [existing] : plan.resources.workspaces;
|
||||
if (destinations.length === 1) {
|
||||
const workspace = destinations[0]!;
|
||||
return [
|
||||
{
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(items),
|
||||
},
|
||||
];
|
||||
}
|
||||
const workspaceById = new Map(
|
||||
[
|
||||
...plan.resources.environments,
|
||||
...plan.resources.folders,
|
||||
...plan.resources.httpRequests,
|
||||
...plan.resources.grpcRequests,
|
||||
...plan.resources.websocketRequests,
|
||||
].map((resource) => [resource.id, resource.workspaceId]),
|
||||
);
|
||||
return destinations.map((workspace) => ({
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(
|
||||
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
|
||||
),
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings
|
||||
.filter(
|
||||
(warning, index, warnings) =>
|
||||
warnings.findIndex(
|
||||
(other) =>
|
||||
other.title === warning.title &&
|
||||
other.detail === warning.detail &&
|
||||
other.level === warning.level,
|
||||
) === index,
|
||||
)
|
||||
.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.level}:${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
{workspaceRoots.map((root) => (
|
||||
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||
<CheckboxTree
|
||||
node={root}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) =>
|
||||
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
|
||||
(n.data.kind === "item" && n.data.item.action === "ignored")
|
||||
}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => (
|
||||
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
footerNote !== "" ? (
|
||||
<div className="text-xs text-text-subtle">{footerNote}</div>
|
||||
) : undefined
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Back",
|
||||
disabled: isLoading,
|
||||
onClick: () => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done",
|
||||
color: "primary",
|
||||
isLoading,
|
||||
onClick: handleCommit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const destinationPicker = (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(workspaces.length === 0 && "hidden")}
|
||||
>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import into"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="h-full pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<ImportSourceList
|
||||
sources={sources}
|
||||
detections={detections}
|
||||
disabled={isLoading}
|
||||
onAdd={addSources}
|
||||
onRemove={removeSource}
|
||||
onError={setSourceError}
|
||||
/>
|
||||
|
||||
{sourceError != null && (
|
||||
<Banner color="warning">
|
||||
<FormattedError>{errorMessage(sourceError)}</FormattedError>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
|
||||
<div className="text-xs text-text-subtle">
|
||||
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
|
||||
<InlineButton
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</InlineButton>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={destinationPicker}
|
||||
actions={[
|
||||
{ label: "Cancel", disabled: isLoading, onClick: cancel },
|
||||
{
|
||||
label: isLoading ? "Analyzing" : "Preview Import",
|
||||
color: "primary",
|
||||
disabled:
|
||||
sources.length === 0 ||
|
||||
!allDetected ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null),
|
||||
isLoading,
|
||||
onClick: handlePreview,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
{row.kind === "destination" && (
|
||||
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
|
||||
)}
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" color="success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
{ value: "keep_mine", label: "Keep mine" },
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
label != null && (
|
||||
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
color: ComponentProps<typeof Chip>["color"];
|
||||
}) {
|
||||
return (
|
||||
<Chip color={color}>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "success";
|
||||
case "update":
|
||||
return "info";
|
||||
case "delete":
|
||||
return "danger";
|
||||
case "keep_local":
|
||||
return item.selected ? "warning" : "default";
|
||||
case "unchanged":
|
||||
case "conflict":
|
||||
case "ignored":
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "new";
|
||||
case "update":
|
||||
return "updated";
|
||||
case "delete":
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.parentId != null && byId.has(item.parentId)) {
|
||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenOf.set(item.parentId, siblings);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
if (selected === 0) return false;
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PathList, splitPath } from "./core/PathList";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
export interface ImportSourcePath {
|
||||
path: string;
|
||||
kind?: "file" | "folder" | "url";
|
||||
}
|
||||
|
||||
export type ImportSourceDetection =
|
||||
| { status: "loading" }
|
||||
| { status: "ok"; importer: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sources: ImportSourcePath[];
|
||||
detections: Record<string, ImportSourceDetection>;
|
||||
disabled?: boolean;
|
||||
onAdd: (sources: ImportSourcePath[]) => void;
|
||||
onRemove: (path: string) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
|
||||
export function isImportFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
|
||||
export function toSourcePath(path: string): ImportSourcePath {
|
||||
return { path, kind: isImportFilePath(path) ? undefined : "url" };
|
||||
}
|
||||
|
||||
export function ImportSourceList({
|
||||
sources,
|
||||
detections,
|
||||
disabled,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onError,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [showPathInput, setShowPathInput] = useState(false);
|
||||
const pathInputRef = useRef<{ focus: () => void }>(null);
|
||||
const trimmedPath = pathInput.trim();
|
||||
|
||||
const openPathInput = () => {
|
||||
setShowPathInput(true);
|
||||
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
|
||||
setTimeout(() => pathInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const closePathInput = () => {
|
||||
setPathInput("");
|
||||
setShowPathInput(false);
|
||||
};
|
||||
|
||||
const addPath = () => {
|
||||
if (!trimmedPath || disabled) return;
|
||||
onAdd([toSourcePath(trimmedPath)]);
|
||||
closePathInput();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "leave") {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
const { x, y } = event.position;
|
||||
const isOver =
|
||||
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
if (event.type === "drop") {
|
||||
if (isOver) onAdd(event.paths.map((path) => ({ path })));
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(isOver);
|
||||
}
|
||||
});
|
||||
}, [disabled, onAdd]);
|
||||
|
||||
const pick = async (directory: boolean) => {
|
||||
try {
|
||||
const selected = await platform.dialog.open({
|
||||
title: directory ? "Select Import Folder" : "Select Import Files",
|
||||
multiple: true,
|
||||
directory,
|
||||
});
|
||||
if (selected == null) return;
|
||||
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addPath();
|
||||
} else if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closePathInput();
|
||||
}
|
||||
};
|
||||
|
||||
const pathInputEl = (
|
||||
<PlainInput
|
||||
ref={pathInputRef}
|
||||
label="URL or file path"
|
||||
hideLabel
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={disabled}
|
||||
placeholder="Paste a URL or a file path"
|
||||
onChange={setPathInput}
|
||||
onKeyDownCapture={handlePathKeyDown}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-1 my-1 mr-1">
|
||||
{trimmedPath !== "" && (
|
||||
<Button size="xs" variant="border" onClick={addPath}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
|
||||
{isHovering ? (
|
||||
<span>Drop to add</span>
|
||||
) : (
|
||||
<span>
|
||||
Choose{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(false)}>
|
||||
files
|
||||
</InlineButton>
|
||||
, a{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(true)}>
|
||||
folder
|
||||
</InlineButton>
|
||||
, or a{" "}
|
||||
<InlineButton disabled={disabled} onClick={openPathInput}>
|
||||
URL
|
||||
</InlineButton>
|
||||
, or drag them here
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
|
||||
</span>
|
||||
</div>
|
||||
{showPathInput && pathInputEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PathList
|
||||
ref={ref}
|
||||
hovering={isHovering}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
addLabel="Add files, a folder, or a URL"
|
||||
addHint="or drop them here"
|
||||
addItems={[
|
||||
{ label: "Files", icon: "file", onSelect: () => pick(false) },
|
||||
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
|
||||
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
|
||||
]}
|
||||
items={sources.map((source) => {
|
||||
const detection = detections[source.path];
|
||||
return {
|
||||
path: source.path,
|
||||
icon: sourceIcon(source),
|
||||
failed: detection?.status === "error",
|
||||
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
|
||||
};
|
||||
})}
|
||||
>
|
||||
{showPathInput && pathInputEl}
|
||||
</PathList>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionSubtitle({
|
||||
path,
|
||||
detection,
|
||||
}: {
|
||||
path: string;
|
||||
detection: ImportSourceDetection | undefined;
|
||||
}) {
|
||||
const { parent } = splitPath(path);
|
||||
if (detection?.status === "loading") {
|
||||
return (
|
||||
<>
|
||||
<LoadingIcon size="xs" />
|
||||
Detecting format
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detection?.status === "error") return detection.message;
|
||||
return (
|
||||
<>
|
||||
{detection?.status === "ok" && <span>{detection.importer}</span>}
|
||||
{detection?.status === "ok" && parent !== "" && <span>·</span>}
|
||||
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceIcon({ kind }: ImportSourcePath) {
|
||||
switch (kind) {
|
||||
case "url":
|
||||
return "globe";
|
||||
case "folder":
|
||||
return "folder";
|
||||
case "file":
|
||||
return "file";
|
||||
case undefined:
|
||||
return "import";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
HStack,
|
||||
LoadingIcon,
|
||||
AudioViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
} 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";
|
||||
|
||||
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,212 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import {
|
||||
createExampleWorkspace,
|
||||
createFreshWorkspace,
|
||||
type OnboardingChoice,
|
||||
recordOnboardingChoice,
|
||||
} from "../lib/onboarding";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/** Shown instead of a workspace when there are none. */
|
||||
export function Onboarding() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { capabilities } = platform;
|
||||
const canImport = capabilities.plugins;
|
||||
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
|
||||
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||
|
||||
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||
recordOnboardingChoice(choice);
|
||||
setBusy(choice);
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
id: "onboarding-failed",
|
||||
title: "Something went wrong",
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="x-theme-appHeader bg-surface"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
/>
|
||||
<div className="overflow-auto px-6 py-10 grid">
|
||||
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Heading>How would you like to get started?</Heading>
|
||||
<p className="text-text-subtle">
|
||||
{canImport
|
||||
? "Bring over existing work, try a real API, or start fresh."
|
||||
: "Try a real API, or start fresh."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canImport && (
|
||||
<StartOption
|
||||
color="primary"
|
||||
icon="folder_input"
|
||||
title="Migrate from another tool"
|
||||
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
|
||||
busy={busy === "import"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import", () => importData.mutateAsync())}
|
||||
/>
|
||||
)}
|
||||
<StartOption
|
||||
color="info"
|
||||
icon="flask"
|
||||
title="Try Yaak with a real API"
|
||||
description="Ready-made requests you can send right away"
|
||||
busy={busy === "example"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("example", createExampleWorkspace)}
|
||||
/>
|
||||
<StartOption
|
||||
color="success"
|
||||
icon="plus"
|
||||
title="Start fresh"
|
||||
description="An empty workspace for your first request"
|
||||
busy={busy === "fresh"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("fresh", createFreshWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAlreadyUsing && (
|
||||
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.localFiles && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("open_folder", async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir == null) return;
|
||||
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||
})}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
)}
|
||||
{capabilities.git && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("clone_git", () => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
})}
|
||||
>
|
||||
Clone repository
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="import" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartOption({
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
color: Color;
|
||||
icon: IconProps["icon"];
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||
"outline-border-focus focus-visible:outline-2",
|
||||
disabled && !busy && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-banner--${color}`,
|
||||
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||
)}
|
||||
>
|
||||
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
<div className="text-sm text-text-subtle">{description}</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
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,134 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
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 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: "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,118 +0,0 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
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,33 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** The shared method menu, writing to the request and coloured by the user's setting */
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
},
|
||||
[request],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseRequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={settings.coloredMethods}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
CountBadge,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
type SettingsTabDef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { SettingsAgents } from "./SettingsAgents";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
import { SettingsHotkeys } from "./SettingsHotkeys";
|
||||
import { SettingsInterface } from "./SettingsInterface";
|
||||
import { SettingsLicense } from "./SettingsLicense";
|
||||
import { SettingsPlugins } from "./SettingsPlugins";
|
||||
import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_AGENTS = "agents";
|
||||
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_AGENTS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
|
||||
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
|
||||
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
|
||||
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
|
||||
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
|
||||
[TAB_PLUGINS]: {
|
||||
icon: "puzzle",
|
||||
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
|
||||
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
|
||||
contentClassName: "h-full grid grid-rows-1",
|
||||
},
|
||||
[TAB_AGENTS]: {
|
||||
icon: "sparkles",
|
||||
hidden: !platform.capabilities.agents,
|
||||
content: <SettingsAgents />,
|
||||
},
|
||||
[TAB_CERTIFICATES]: {
|
||||
icon: "shield_check",
|
||||
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
|
||||
content: <SettingsCertificates />,
|
||||
},
|
||||
[TAB_PROXY]: {
|
||||
icon: "wifi",
|
||||
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
|
||||
content: <SettingsProxy />,
|
||||
},
|
||||
[TAB_LICENSE]: {
|
||||
icon: "key_round",
|
||||
hidden: !appInfo.featureLicense,
|
||||
rightSlot:
|
||||
licenseCheck.check.data?.status === "personal_use" ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
content: <SettingsLicense />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsDialog
|
||||
defaultTab={mainTab}
|
||||
titlebar={
|
||||
hide
|
||||
? null
|
||||
: {
|
||||
osType: platform.osType(),
|
||||
hideWindowControls: settings.hideWindowControls,
|
||||
useNativeTitlebar: settings.useNativeTitlebar,
|
||||
interfaceScale: settings.interfaceScale,
|
||||
}
|
||||
}
|
||||
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import {
|
||||
AGENT_CLIENTS,
|
||||
type AgentClient,
|
||||
cliSetup,
|
||||
skillInstallCommand,
|
||||
} from "../../lib/agentSetup";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
|
||||
|
||||
export function SettingsAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
|
||||
key: "agents_setup_client",
|
||||
fallback: "claude_code",
|
||||
});
|
||||
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
|
||||
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
|
||||
const cliVersion = useQuery({
|
||||
queryKey: ["cli_version"],
|
||||
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
|
||||
initialData: appInfo.cliVersion,
|
||||
});
|
||||
const setupStatus = useQuery({
|
||||
queryKey: ["agent_setup_status", client],
|
||||
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
|
||||
});
|
||||
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
|
||||
|
||||
useEffect(() => {
|
||||
return platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const cliInstalled = cliVersion.data != null;
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Agents"
|
||||
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
|
||||
>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
<SegmentedControl
|
||||
name="agents_setup_client"
|
||||
label="Agent"
|
||||
hideLabel
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={AGENT_CLIENTS}
|
||||
/>
|
||||
</div>
|
||||
{cliInstalled ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
|
||||
<Icon icon="check" size="sm" color="success" />
|
||||
<span>
|
||||
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<Banner color="info">
|
||||
<div className="flex items-center gap-2 text text-text">
|
||||
<Icon icon="square_terminal" color="info" />
|
||||
Install the Yaak CLI first
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">
|
||||
Agents launch it to reach Yaak, even with the app closed.
|
||||
</p>
|
||||
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
|
||||
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
|
||||
<Link href="https://yaak.app/docs/cli">Docs</Link>
|
||||
<span className="text-text-subtlest">
|
||||
Checks again when you come back to Yaak.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
{mcpServerPlugin != null && (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<DeprecatedPluginBanner
|
||||
plugin={mcpServerPlugin.plugin}
|
||||
name={mcpServerPlugin.info.displayName}
|
||||
>
|
||||
The two steps below replace it. Uninstall it once your agent is connected.
|
||||
</DeprecatedPluginBanner>
|
||||
</div>
|
||||
)}
|
||||
<SettingCommand
|
||||
title="Add the MCP server"
|
||||
description={cli.where}
|
||||
snippet={cli.snippet}
|
||||
language={cli.language}
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.mcpServer ? "Added" : null}
|
||||
/>
|
||||
<SettingCommand
|
||||
title="Install the skill"
|
||||
description={
|
||||
<>
|
||||
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
|
||||
</>
|
||||
}
|
||||
snippet={skillInstallCommand(client)}
|
||||
language="shell"
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.skill ? "Installed" : null}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorkspaceSettingsMovedBanner =
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<SharedSettingsGeneral
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||
extraSections={
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Link } from "../core/Link";
|
||||
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SharedSettingsInterface
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
leadingSections={
|
||||
<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>
|
||||
}
|
||||
trailingSections={
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,17 +0,0 @@
|
||||
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
<CargoFeature feature="license">
|
||||
<SettingsLicenseCmp />
|
||||
</CargoFeature>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const bound = useSettingsView();
|
||||
if (bound == null) return null;
|
||||
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { InlineCode, SettingsPage } 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 (
|
||||
<SettingsPage
|
||||
title="Proxy"
|
||||
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
|
||||
banner={<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>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
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,15 +0,0 @@
|
||||
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const [remember, setRemember] = useState<boolean>(false);
|
||||
|
||||
const open = async (inNewWindow: boolean) => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<p>
|
||||
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
||||
</p>
|
||||
{settings && (
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
)}
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "New Window",
|
||||
rightSlot: <Icon icon="external_link" />,
|
||||
onClick: () => open(true),
|
||||
},
|
||||
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
|
||||
]}
|
||||
/>
|
||||
</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,152 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { Tokens } from "@yaakapp-internal/templates";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
|
||||
|
||||
interface FormProps {
|
||||
data: Record<string, string | boolean>;
|
||||
onChange: (values: Record<string, string | boolean>) => void;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parse: vi.fn<(text: string) => Tokens>(),
|
||||
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
|
||||
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
|
||||
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
|
||||
form: null as FormProps | null,
|
||||
config: {
|
||||
name: "timestamp.format",
|
||||
args: [
|
||||
{ type: "text", name: "date" },
|
||||
{ type: "text", name: "format" },
|
||||
],
|
||||
} as TemplateFunction,
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
InlineCode: "code",
|
||||
LoadingIcon: () => null,
|
||||
useDebouncedValue: (value: string) => value,
|
||||
}));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
|
||||
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
|
||||
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
|
||||
useTemplateFunctionConfig: () => ({ data: mocks.config }),
|
||||
}));
|
||||
vi.mock("../hooks/useTemplateTokensToString", () => ({
|
||||
templateTokensToString: mocks.print,
|
||||
useTemplateTokensToString: mocks.serialize,
|
||||
}));
|
||||
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
|
||||
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
|
||||
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
|
||||
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
|
||||
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
|
||||
vi.mock("./DynamicForm", () => ({
|
||||
DYNAMIC_FORM_NULL_ARG: "__NULL__",
|
||||
DynamicForm: (props: FormProps) => {
|
||||
mocks.form = props;
|
||||
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const base = pathToFileURL(
|
||||
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
|
||||
);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
mocks.parse.mockImplementation(bindings.parse_template);
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
mocks.form = null;
|
||||
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
|
||||
// inspect what Preview and Save receive, without duplicating the backend's printer.
|
||||
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
|
||||
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function functionValue(tokens: Tokens) {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
|
||||
return first.val;
|
||||
}
|
||||
|
||||
test.each([
|
||||
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
|
||||
["date_var", "${[ date_var ]}"],
|
||||
["dates.previous", "${[ dates.previous ]}"],
|
||||
["'${[ date_var ]}'", "${[ date_var ]}"],
|
||||
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
|
||||
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
|
||||
const originalDate = functionValue(initialTokens).args[0];
|
||||
const saved = vi.fn<(value: string) => void>();
|
||||
mocks.print.mockResolvedValue(display);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={mocks.config}
|
||||
initialTokens={initialTokens}
|
||||
model={{ id: "wk_test" } as Workspace}
|
||||
onChange={saved}
|
||||
hide={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
|
||||
|
||||
async function submit() {
|
||||
const form = host.querySelector("form");
|
||||
if (form == null) throw new Error("Dialog did not initialize");
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const value = saved.mock.calls.at(-1)?.[0];
|
||||
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
|
||||
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
|
||||
}
|
||||
|
||||
expect((await submit())[0]).toEqual(originalDate);
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
|
||||
expect(await submit()).toEqual([
|
||||
originalDate,
|
||||
{ name: "format", value: { type: "str", text: "yyyy" } },
|
||||
]);
|
||||
|
||||
const edited = "${[ timestamp.offset(expression='-2h') ]}";
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||
import { showErrorDialog } from "./ErrorBoundary";
|
||||
|
||||
export type { ToastInstance } from "@yaakapp-internal/ui";
|
||||
|
||||
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
|
||||
onSend: () => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
/** The shared URL bar, sending and focusing on the client's hotkeys */
|
||||
export const UrlBar = memo(function UrlBar(props: Props) {
|
||||
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { Workspace, WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { workspaceMetasAtom } from "@yaakapp-internal/models";
|
||||
import { getDefaultStore, type PrimitiveAtom } from "jotai";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import { WorkspaceSettingsDialog } from "./WorkspaceSettingsDialog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rpc: vi.fn<(cmd: string, payload: unknown) => Promise<unknown>>(),
|
||||
mergeModelsInStore: vi.fn(),
|
||||
deleteModelWithConfirm: vi.fn<(model: unknown) => Promise<boolean>>(),
|
||||
workspace: {
|
||||
id: "wk_test",
|
||||
model: "workspace",
|
||||
name: "Broken",
|
||||
settingDnsOverrides: [],
|
||||
} as unknown as Workspace,
|
||||
workspaceMeta: {
|
||||
id: "wm_test",
|
||||
model: "workspace_meta",
|
||||
workspaceId: "wk_test",
|
||||
settingSyncDir: "/sync/broken",
|
||||
encryptionKey: null,
|
||||
} as WorkspaceMeta,
|
||||
}));
|
||||
const { workspace, workspaceMeta } = mocks;
|
||||
|
||||
vi.mock("@yaakapp-internal/models", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
return {
|
||||
workspacesAtom: atom([mocks.workspace]),
|
||||
// Starts empty in each test: the workspace failed to load, so the store has no meta for it
|
||||
workspaceMetasAtom: atom<WorkspaceMeta[]>([]),
|
||||
mergeModelsInStore: mocks.mergeModelsInStore,
|
||||
patchModel: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Banner: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
|
||||
CountBadge: () => null,
|
||||
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
InlineCode: ({ children }: { children: ReactNode }) => <code>{children}</code>,
|
||||
}));
|
||||
vi.mock("../hooks/useAuthTab", () => ({ useAuthTab: () => [] }));
|
||||
vi.mock("../hooks/useHeadersTab", () => ({ useHeadersTab: () => [] }));
|
||||
vi.mock("../hooks/useInheritedHeaders", () => ({ useInheritedHeaders: () => [] }));
|
||||
vi.mock("../lib/deleteModelWithConfirm", () => ({
|
||||
deleteModelWithConfirm: mocks.deleteModelWithConfirm,
|
||||
}));
|
||||
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
|
||||
vi.mock("../lib/router", () => ({ router: { navigate: vi.fn() } }));
|
||||
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
|
||||
vi.mock("./CopyIconButton", () => ({ CopyIconButton: () => null }));
|
||||
vi.mock("./core/Button", () => ({
|
||||
Button: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("./core/PlainInput", () => ({
|
||||
PlainInput: ({ label, defaultValue }: { label: string; defaultValue: string }) => (
|
||||
<input aria-label={label} defaultValue={defaultValue} />
|
||||
),
|
||||
}));
|
||||
vi.mock("./core/SettingRow", () => ({
|
||||
SettingsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SettingsSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
// Render every tab at once so the test can see all of them
|
||||
vi.mock("./core/Tabs/Tabs", () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabContent: ({ children, value }: { children: ReactNode; value: string }) => (
|
||||
<div data-tab={value}>{children}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./DnsOverridesEditor", () => ({ DnsOverridesEditor: () => null }));
|
||||
vi.mock("./HeadersEditor", () => ({ HeadersEditor: () => null }));
|
||||
vi.mock("./HttpAuthenticationEditor", () => ({ HttpAuthenticationEditor: () => null }));
|
||||
vi.mock("./MarkdownEditor", () => ({ MarkdownEditor: () => null }));
|
||||
vi.mock("./ModelSettingsEditor", () => ({ ModelSettingsEditor: () => null }));
|
||||
vi.mock("./SyncToFilesystemSetting", () => ({
|
||||
SyncToFilesystemSetting: ({ value }: { value: { filePath: string | null } }) => (
|
||||
<div data-testid="sync-setting">{value.filePath}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./WorkspaceEncryptionSetting", () => ({
|
||||
WorkspaceEncryptionSetting: () => <div data-testid="encryption-setting" />,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Behave like the real store: merged models land in the atom the dialog reads
|
||||
const store = getDefaultStore();
|
||||
const metasAtom = workspaceMetasAtom as unknown as PrimitiveAtom<WorkspaceMeta[]>;
|
||||
store.set(metasAtom, []);
|
||||
mocks.mergeModelsInStore.mockImplementation((_model: string, models: WorkspaceMeta[]) =>
|
||||
store.set(metasAtom, (prev) => [...prev, ...models]),
|
||||
);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
await act(async () =>
|
||||
root.render(<WorkspaceSettingsDialog workspaceId="wk_test" hide={() => {}} />),
|
||||
);
|
||||
}
|
||||
|
||||
function tab(value: string) {
|
||||
const el = host.querySelector(`[data-tab="${value}"]`);
|
||||
if (el == null) throw new Error(`No ${value} tab`);
|
||||
return el;
|
||||
}
|
||||
|
||||
function deleteButton() {
|
||||
return Array.from(host.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Delete Workspace",
|
||||
);
|
||||
}
|
||||
|
||||
test("a workspace with no meta in the store still offers Delete", async () => {
|
||||
mocks.rpc.mockReturnValue(new Promise(() => {}));
|
||||
mocks.deleteModelWithConfirm.mockResolvedValue(false);
|
||||
await render();
|
||||
|
||||
expect(host.textContent).not.toContain("not found");
|
||||
const button = deleteButton();
|
||||
expect(button).toBeDefined();
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(mocks.deleteModelWithConfirm).toHaveBeenCalledWith(workspace, { confirmName: "Broken" });
|
||||
});
|
||||
|
||||
test("a missing meta is fetched, put in the store, and its settings rendered", async () => {
|
||||
mocks.rpc.mockResolvedValue(workspaceMeta);
|
||||
await render();
|
||||
|
||||
expect(mocks.rpc).toHaveBeenCalledWith("cmd_get_workspace_meta", { workspaceId: "wk_test" });
|
||||
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("workspace_meta", [workspaceMeta]);
|
||||
|
||||
const settings = tab("settings");
|
||||
expect(settings.querySelector('[data-testid="sync-setting"]')?.textContent).toBe("/sync/broken");
|
||||
expect(settings.querySelector('[data-testid="encryption-setting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("a meta that cannot be loaded shows the error only where sync and encryption go", async () => {
|
||||
mocks.rpc.mockRejectedValue("database is locked");
|
||||
await render();
|
||||
|
||||
// The error is the one banner, inside the Settings tab, in place of sync and encryption
|
||||
const banners = host.querySelectorAll('[role="alert"]');
|
||||
expect(banners).toHaveLength(1);
|
||||
expect(tab("settings").contains(banners[0] ?? null)).toBe(true);
|
||||
expect(banners[0]?.textContent).toContain("database is locked");
|
||||
expect(host.querySelector('[data-testid="sync-setting"]')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="encryption-setting"]')).toBeNull();
|
||||
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
|
||||
|
||||
// The workspace's own settings and Delete still render
|
||||
const general = tab("general");
|
||||
expect(general.querySelector<HTMLInputElement>('input[aria-label="Name"]')?.value).toBe("Broken");
|
||||
expect(general.contains(deleteButton() ?? null)).toBe(true);
|
||||
});
|
||||
@@ -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,2 +0,0 @@
|
||||
export type { CheckboxProps } from "@yaakapp-internal/ui";
|
||||
export { Checkbox } from "@yaakapp-internal/ui";
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CheckboxProps } from "./Checkbox";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
export interface CheckboxTreeNode<T> {
|
||||
key: string;
|
||||
data: T;
|
||||
children: CheckboxTreeNode<T>[];
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
node: CheckboxTreeNode<T>;
|
||||
depth?: number;
|
||||
/** Return "hidden" to render row alignment space instead of a checkbox */
|
||||
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
||||
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
||||
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
}
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
const selected = props.isRowSelected?.(node) ?? false;
|
||||
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
||||
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
||||
const rowContent = (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
||||
{props.renderRow(node)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
selected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
{hasVisibleChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||
aria-expanded={!collapsed}
|
||||
className="shrink-0 text-text-subtlest hocus:text-text"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-4 shrink-0" />
|
||||
)}
|
||||
{checked === "hidden" ? (
|
||||
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
||||
hideLabel
|
||||
disabled={props.isCheckboxDisabled?.(node)}
|
||||
onChange={(checked) => props.onCheck(node, checked)}
|
||||
/>
|
||||
)}
|
||||
{selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 flex text-left"
|
||||
onClick={() => props.onSelectRow?.(node)}
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
node.children.map((child) => (
|
||||
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasRelevantNode<T>(
|
||||
node: CheckboxTreeNode<T>,
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
||||
): boolean {
|
||||
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
color?: Color | "default";
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** A small tinted label, like a Gmail or Trello label */
|
||||
export function Chip({ color = "default", className, children }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
|
||||
"inline-flex items-center gap-1",
|
||||
color === "default" && "text-text-subtle",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
color === "info" && "text-info",
|
||||
color === "success" && "text-success",
|
||||
color === "notice" && "text-notice",
|
||||
color === "warning" && "text-warning",
|
||||
color === "danger" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
DetailsBanner as BaseDetailsBanner,
|
||||
type DetailsBannerProps,
|
||||
type StoredValue,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtom, type WritableAtom } from "jotai";
|
||||
import { type SetStateAction, useMemo } from "react";
|
||||
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
|
||||
|
||||
export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
|
||||
return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
|
||||
}
|
||||
|
||||
export function useDetailsBannerStoredValue<T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
) {
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
|
||||
const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
|
||||
() =>
|
||||
key != null
|
||||
? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
|
||||
: atom(fallback),
|
||||
[key],
|
||||
);
|
||||
return useAtom(valueAtom);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
|
||||
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
|
||||
@@ -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,10 +0,0 @@
|
||||
export type {
|
||||
ContextMenuProps,
|
||||
DropdownItem,
|
||||
DropdownItemContent,
|
||||
DropdownItemDefault,
|
||||
DropdownItemSeparator,
|
||||
DropdownProps,
|
||||
DropdownRef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
export { ContextMenu, Dropdown } from "@yaakapp-internal/ui";
|
||||
@@ -1,195 +0,0 @@
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
|
||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
|
||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||
import { jotaiStore } from "../../../lib/jotai";
|
||||
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
|
||||
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
|
||||
import { getLanguageExtension } from "./extensions";
|
||||
import type { GenericCompletionConfig } from "./genericCompletion";
|
||||
import { largeValues, mediaValues } from "./largeValues";
|
||||
|
||||
export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
autocompleteFunctions?: boolean;
|
||||
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
|
||||
extraExtensions?: Extension[] | Extension;
|
||||
forcedEnvironmentId?: string;
|
||||
graphQLSchema?: GraphQLSchema | null;
|
||||
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
|
||||
};
|
||||
|
||||
const emptyVariables: WrappedEnvironmentVariable[] = [];
|
||||
|
||||
export function Editor({
|
||||
autocomplete,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
format,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
language,
|
||||
lintExtension,
|
||||
readOnly,
|
||||
wrapLines,
|
||||
...props
|
||||
}: EditorProps) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
|
||||
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
|
||||
const environmentVariables = useMemo(() => {
|
||||
if (!autocompleteVariables) return emptyVariables;
|
||||
return typeof autocompleteVariables === "function"
|
||||
? allEnvironmentVariables.filter(autocompleteVariables)
|
||||
: allEnvironmentVariables;
|
||||
}, [allEnvironmentVariables, autocompleteVariables]);
|
||||
|
||||
if (settings && wrapLines === undefined) {
|
||||
wrapLines = settings.editorSoftWrap;
|
||||
}
|
||||
|
||||
if (props.disabled) {
|
||||
readOnly = true;
|
||||
}
|
||||
|
||||
if (format == null && !readOnly) {
|
||||
format =
|
||||
language === "json"
|
||||
? tryFormatJson
|
||||
: language === "xml" || language === "html"
|
||||
? tryFormatXml
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Template dialogs are opened against the editor the tag was clicked in
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const { setRef } = props;
|
||||
const handleSetRef = useCallback(
|
||||
(view: EditorView | null) => {
|
||||
viewRef.current = view;
|
||||
setRef?.(view);
|
||||
},
|
||||
[setRef],
|
||||
);
|
||||
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const show = () => {
|
||||
if (viewRef.current === null) return;
|
||||
TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
|
||||
};
|
||||
|
||||
if (fn.name === "secure") {
|
||||
withEncryptionEnabled(show);
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClickVariable = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
|
||||
await editEnvironment(v.environment, { addOrFocusVariable: v.variable });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClickMissingVariable = useCallback(async (name: string) => {
|
||||
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
|
||||
await editEnvironment(activeEnvironment, {
|
||||
addOrFocusVariable: { name, value: "", enabled: true },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [, { focusParamValue }] = useRequestEditor();
|
||||
const onClickPathParameter = useCallback(
|
||||
async (name: string) => {
|
||||
focusParamValue(name);
|
||||
},
|
||||
[focusParamValue],
|
||||
);
|
||||
|
||||
const completionOptions = useTemplateFunctionCompletionOptions(
|
||||
onClickFunction,
|
||||
!!autocompleteFunctions,
|
||||
);
|
||||
|
||||
const languageExtension = useMemo(
|
||||
() =>
|
||||
getLanguageExtension({
|
||||
useTemplating,
|
||||
language,
|
||||
lintExtension,
|
||||
hideGutter,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
completionOptions,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
}),
|
||||
[
|
||||
language,
|
||||
lintExtension,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
completionOptions,
|
||||
useTemplating,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
{...props}
|
||||
editableExtensions={mediaValues}
|
||||
extensions={extraExtensions}
|
||||
format={format}
|
||||
hideGutter={hideGutter}
|
||||
keyDownHandler={startCompletionOnHotkey}
|
||||
keymap={settings.editorKeymap}
|
||||
language={codeEditorLanguage(language)}
|
||||
languageExtension={languageExtension}
|
||||
lintExtension={lintExtension}
|
||||
readOnly={readOnly}
|
||||
readOnlyExtensions={largeValues}
|
||||
setRef={handleSetRef}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's own languages bring their extension along, so CodeEditor needn't know them */
|
||||
function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
|
||||
return language === "url" || language === "pairs" || language === "timeline" ? null : language;
|
||||
}
|
||||
|
||||
function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
|
||||
if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import {
|
||||
closeBracketsExtensions,
|
||||
getLanguageExtension as getBaseLanguageExtension,
|
||||
type LanguageSupports,
|
||||
readonlyExtensions as baseReadonlyExtensions,
|
||||
syntaxExtensions,
|
||||
} from "@yaakapp-internal/ui/editor";
|
||||
import { graphql } from "cm6-graphql";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
|
||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||
import { jotaiStore } from "../../../lib/jotai";
|
||||
import { renderMarkdown } from "../../../lib/markdown";
|
||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||
import type { EditorProps } from "./Editor";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
import { largeValues } from "./largeValues";
|
||||
import { pairs } from "./pairs/extension";
|
||||
import { timeline } from "./timeline/extension";
|
||||
import type { TwigCompletionOption } from "./twig/completion";
|
||||
import { twig } from "./twig/extension";
|
||||
import { pathParametersPlugin } from "./twig/pathParameters";
|
||||
import { url } from "./url/extension";
|
||||
|
||||
const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
|
||||
|
||||
export function getLanguageExtension({
|
||||
useTemplating,
|
||||
language = "text",
|
||||
lintExtension,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
hideGutter,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
completionOptions,
|
||||
graphQLSchema,
|
||||
}: {
|
||||
useTemplating: boolean;
|
||||
environmentVariables: WrappedEnvironmentVariable[];
|
||||
onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
|
||||
onClickPathParameter: (name: string) => void;
|
||||
completionOptions: TwigCompletionOption[];
|
||||
graphQLSchema: GraphQLSchema | null;
|
||||
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
|
||||
// GraphQL is a special exception
|
||||
if (language === "graphql") {
|
||||
return [
|
||||
graphql(graphQLSchema ?? undefined, {
|
||||
async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {
|
||||
if (!gqlCompletionItem.documentation) return null;
|
||||
const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = innerHTML;
|
||||
return span;
|
||||
},
|
||||
onShowInDocs(field, type, parentType) {
|
||||
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
|
||||
if (activeRequestId == null) return;
|
||||
jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({
|
||||
...v,
|
||||
[activeRequestId]: { field, type, parentType },
|
||||
}));
|
||||
},
|
||||
}),
|
||||
[closeBracketsExtensions],
|
||||
];
|
||||
}
|
||||
|
||||
return getBaseLanguageExtension({
|
||||
language,
|
||||
hideGutter,
|
||||
languages,
|
||||
lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
|
||||
extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
|
||||
wrap: useTemplating
|
||||
? (base, extraExtensions) =>
|
||||
twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
extraExtensions,
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Nobody is editing this, so every rule applies
|
||||
export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
|
||||
// The linter only reads the document text off the view
|
||||
function view(doc: string): EditorView {
|
||||
return { state: { doc: { toString: () => doc } } } as EditorView;
|
||||
}
|
||||
|
||||
const lint = jsonParseLinter();
|
||||
|
||||
describe("jsonParseLinter", () => {
|
||||
test("reports nothing for an empty document", () => {
|
||||
expect(lint(view(" "))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports nothing for valid JSON", () => {
|
||||
expect(lint(view('{"a": 1}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports a message for invalid JSON", () => {
|
||||
expect(lint(view('{"a": }'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("ignores a template tag standing in for a value", () => {
|
||||
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains an escaped quote", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("still reports errors around a tag with a quoted `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps the tag's length so error offsets stay correct", () => {
|
||||
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
|
||||
const [error] = lint(view(doc));
|
||||
expect(error?.from).toEqual(doc.indexOf('"${['));
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
type JsonLintOptions,
|
||||
jsonParseLinter as baseJsonParseLinter,
|
||||
} from "@yaakapp-internal/ui/editor";
|
||||
import { replaceTemplateTags } from "../../../lib/templateTags";
|
||||
|
||||
export function jsonParseLinter(options?: Omit<JsonLintOptions, "escape">) {
|
||||
return baseJsonParseLinter({ ...options, escape: escapeTemplateTags });
|
||||
}
|
||||
|
||||
// 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.
|
||||
function escapeTemplateTags(doc: string): string {
|
||||
return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
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";
|
||||
import { text } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
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,7 +0,0 @@
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import { Editor } from "./LazyEditor";
|
||||
|
||||
/** Renders a ui component's editor as the client's, with its settings and value handling */
|
||||
export function renderEditor({ extensions, ...props }: CodeEditorProps) {
|
||||
return <Editor {...props} extraExtensions={extensions} />;
|
||||
}
|
||||
@@ -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,155 +0,0 @@
|
||||
/* oxlint-disable no-template-curly-in-string */
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { twigCompletion, type TwigCompletionOption } from "./completion";
|
||||
import { parser } from "./twig";
|
||||
import { singleLineExtensions } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
const options: TwigCompletionOption[] = [
|
||||
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
|
||||
{ name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
|
||||
];
|
||||
const source = twigCompletion({ options });
|
||||
|
||||
// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
|
||||
function complete(input: string, label: string, extensions: Extension = []) {
|
||||
const cursor = input.indexOf("|");
|
||||
if (cursor < 0) throw new Error("Missing cursor marker");
|
||||
let state = EditorState.create({
|
||||
doc: input.slice(0, cursor) + input.slice(cursor + 1),
|
||||
selection: { anchor: cursor },
|
||||
extensions,
|
||||
});
|
||||
const result = source(new CompletionContext(state, cursor, false));
|
||||
const completion = result?.options.find((option) => option.label === label);
|
||||
expect(completion).toBeDefined();
|
||||
const view = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch(spec: TransactionSpec) {
|
||||
state = state.update(spec).state;
|
||||
},
|
||||
} as EditorView;
|
||||
if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
|
||||
completion.apply(view, completion, result.from, cursor);
|
||||
return state;
|
||||
}
|
||||
|
||||
function expectValidTag(state: EditorState, expected: string) {
|
||||
expect(state.doc.toString()).toBe(expected);
|
||||
const tags: string[] = [];
|
||||
parser.parse(expected).iterate({
|
||||
enter(node) {
|
||||
expect(node.type.isError).toBe(false);
|
||||
if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
|
||||
},
|
||||
});
|
||||
expect(tags).toHaveLength(1);
|
||||
}
|
||||
|
||||
describe("twig completion", () => {
|
||||
test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
|
||||
"completes across multiline whitespace: %s",
|
||||
(input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
|
||||
);
|
||||
|
||||
test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
|
||||
"completes a function across multiline whitespace: %s",
|
||||
(input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
|
||||
);
|
||||
|
||||
test("preserves surrounding lines and literal pipes", () => {
|
||||
expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
|
||||
"before\n${[ someVar ]}\na | b",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not search past non-whitespace for an opener or closer", () => {
|
||||
expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
|
||||
"prefix\n${[ someVar ]}\nother ]}",
|
||||
);
|
||||
expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
|
||||
"${[ someVar ]}\nother ]}",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["${[|", "${[ |", "${[ |"])(
|
||||
"offers completion immediately after a template opener: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["$", "{", "${", '{"value": "'])(
|
||||
"does not automatically offer empty-name completions after %s",
|
||||
(doc) => {
|
||||
const state = EditorState.create({ doc });
|
||||
expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
|
||||
"completes a namespace before wrapping the final function: %s",
|
||||
(input) => {
|
||||
const namespace = complete(input, "uuid.*");
|
||||
expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
|
||||
const cursor = namespace.selection.main.head;
|
||||
const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
|
||||
expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
|
||||
"completes a variable in a single-line value: %s",
|
||||
(input) => {
|
||||
const state = complete(input, "someVar", singleLineExtensions());
|
||||
expectValidTag(state, "${[ someVar ]}");
|
||||
expect(state.selection.main.head).toBe(state.doc.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
|
||||
"reuses closing delimiters: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
|
||||
"completes a function: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves surrounding JSON and its closing punctuation", () => {
|
||||
expectValidTag(
|
||||
complete('{"value":"${[some|","other":1}', "someVar", json()),
|
||||
'{"value":"${[ someVar ]}","other":1}',
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves surrounding text and other complete tags", () => {
|
||||
expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
|
||||
"${[ someVar ]} ${[ someVar ]} trailing",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not consume closing punctuation without a template opener", () => {
|
||||
expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
|
||||
});
|
||||
|
||||
test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
|
||||
const state = EditorState.create({ doc: "some" });
|
||||
const result = source(new CompletionContext(state, 4, false));
|
||||
expect(result?.validFor).toBeInstanceOf(RegExp);
|
||||
const validFor = result?.validFor as RegExp;
|
||||
expect(validFor.test("someVar")).toBe(true);
|
||||
expect(validFor.test("some ${[some")).toBe(false);
|
||||
expect(validFor.test("uuid.")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
|
||||
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
|
||||
// the tag still closes at the next `]}`.
|
||||
@local tokens {
|
||||
TagClose { "]}" }
|
||||
TagString { "'" (![\\'] | "\\" _)* "'" }
|
||||
@else TagContent
|
||||
}
|
||||
|
||||
@skip { } {
|
||||
TagOpen { "${[" }
|
||||
Tag { TagOpen (TagContent | TagString)+ TagClose }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
@@ -1,27 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LocalTokenGroup, LRParser } from "@lezer/lr";
|
||||
import { highlight } from "./highlight";
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d",
|
||||
stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O",
|
||||
goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text",
|
||||
maxTerm: 11,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~",
|
||||
tokenizers: [
|
||||
1,
|
||||
new LocalTokenGroup(
|
||||
"#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~",
|
||||
94,
|
||||
4,
|
||||
),
|
||||
],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
@@ -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
|
||||
})
|
||||
Loaded 100 of 1720 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user