commit 906fb231cc3c1e085550ecefc8c7616440134cd2
Author: upstream-mirror
Date: Wed Sep 23 17:39:41 2026 +0500
snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
diff --git a/.claude-context.md b/.claude-context.md
new file mode 100644
index 0000000..97e20f6
--- /dev/null
+++ b/.claude-context.md
@@ -0,0 +1,83 @@
+# Claude Context: Detaching Tauri from Yaak
+
+## Goal
+
+Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
+
+## Project Structure
+
+```
+crates/ # Core crates - should NOT depend on Tauri
+crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
+crates-cli/ # CLI crate (yaak-cli)
+```
+
+## Completed Work
+
+### 1. Folder Restructure
+
+- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
+- 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
+
+### 2. Decoupled Crates (no longer depend on Tauri)
+
+- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
+- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
+- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
+- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
+- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
+
+### 3. CLI Implementation
+
+- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
+- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
+- Uses same database as Tauri app via `yaak_models::init_standalone()`
+
+## Remaining Work
+
+### Crates Still Depending on Tauri (in `crates/`)
+
+1. **yaak-git** (3 files) - Moderate complexity
+2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
+3. **yaak-sync** (4 files) - Moderate complexity
+4. **yaak-ws** (5 files) - Moderate complexity
+
+### Pattern for Decoupling
+
+1. Remove Tauri plugin `init()` function from the crate
+2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
+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
+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-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
+- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
+
+## Git Branch
+
+Working on `detach-tauri` branch.
+
+## Recent Commits
+
+```
+c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
+df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
+481e0273 Remove Tauri dependencies from yaak-http and yaak-common
+10568ac3 Add HTTP request sending to yaak-cli
+bcb7d600 Add yaak-cli stub with basic database access
+e718a5f1 Refactor models_ext to use init_standalone from yaak-models
+```
+
+## Testing
+
+- Run `cargo check -p ` to verify a crate builds without Tauri
+- Run `npm run client:dev` to test the Tauri app still works
+- Run `cargo run -p yaak-cli -- --help` to test the CLI
diff --git a/.claude/rules.md b/.claude/rules.md
new file mode 100644
index 0000000..aa0fb39
--- /dev/null
+++ b/.claude/rules.md
@@ -0,0 +1,27 @@
+# Project Rules
+
+## General Development
+
+- **NEVER** commit or push without explicit confirmation
+
+## Build and Lint
+
+- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
+- Run `npm run bootstrap` after changing plugin runtime or MCP server code
+
+## Plugin System
+
+### Backend Constraints
+
+- Always use `UpdateSource::Plugin` when calling database methods from plugin events
+- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
+- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
+
+### MCP Server
+
+- MCP server has **no active window context** - cannot call `window.workspaceId()`
+- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
+
+## Rust Type Generation
+
+- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
diff --git a/.codex/skills/release-generate-release-notes/SKILL.md b/.codex/skills/release-generate-release-notes/SKILL.md
new file mode 100644
index 0000000..f5df99c
--- /dev/null
+++ b/.codex/skills/release-generate-release-notes/SKILL.md
@@ -0,0 +1,52 @@
+---
+name: release-generate-release-notes
+description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
+---
+
+# Generate Release Notes
+
+Generate formatted markdown release notes for a Yaak tag.
+
+## Workflow
+
+1. Determine target tag.
+2. Determine previous comparable tag:
+ - Beta tag: compare against previous beta (if the root version is the same) or stable tag.
+ - Stable tag: compare against previous stable tag.
+3. Collect commits in range:
+ - `git log --oneline ..`
+4. For linked PRs, fetch metadata:
+ - `gh pr view --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
+
+## Formatting Rules
+
+- 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.
+
+## Release Creation Prompt
+
+After producing notes, ask whether to create a draft GitHub release.
+
+If confirmed and release does not yet exist, run:
+
+`gh release create --draft --prerelease --title "Release " --notes ''`
+
+If a draft release for the tag already exists, update it instead:
+
+`gh release edit --title "Release " --notes-file `
+
+Use title format `Release `, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.
diff --git a/.codex/skills/yaak-changelog/SKILL.md b/.codex/skills/yaak-changelog/SKILL.md
new file mode 100644
index 0000000..48cd860
--- /dev/null
+++ b/.codex/skills/yaak-changelog/SKILL.md
@@ -0,0 +1,104 @@
+---
+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`.
diff --git a/.codex/skills/yaak-changelog/agents/openai.yaml b/.codex/skills/yaak-changelog/agents/openai.yaml
new file mode 100644
index 0000000..d4d46b3
--- /dev/null
+++ b/.codex/skills/yaak-changelog/agents/openai.yaml
@@ -0,0 +1,7 @@
+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
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..50e4b2f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+**/node_modules
+dist
+**/dist
+target
+**/target
+.claude
+vendored
+**/vendored
+*.log
+.git
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..67ab542
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
+crates-tauri/yaak-app-client/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
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..c1cde08
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: gschier
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..9b77ea7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,40 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ""
+labels: ""
+assignees: ""
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+
+- OS: [e.g. iOS]
+- Browser [e.g. chrome, safari]
+- Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+
+- Device: [e.g. iPhone6]
+- OS: [e.g. iOS8.1]
+- Browser [e.g. stock browser, safari]
+- Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..c9e2ff5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Bugs, Feedback, Feature Requests, and Questions
+ url: https://feedback.yaak.app
+ about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable."
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0a86c6a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,55 @@
+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
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..49d7b7e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,22 @@
+## Summary
+
+
+
+## Submission
+
+
+
+- [ ] This PR is a bug fix.
+- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
+- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
+- [ ] I 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.
+
+Explicit permission feedback item (required if not a bug fix):
+
+
+
+## Related
+
+
diff --git a/.github/scripts/check-contribution-policy.js b/.github/scripts/check-contribution-policy.js
new file mode 100644
index 0000000..2c7dc57
--- /dev/null
+++ b/.github/scripts/check-contribution-policy.js
@@ -0,0 +1,881 @@
+const fs = require("node:fs");
+
+const COMMENT_MARKER = "";
+
+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(//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.",
+ "",
+ "",
+ "PR description template ",
+ "",
+ "```md",
+ getPullRequestTemplate(),
+ "```",
+ "",
+ " ",
+ );
+ }
+
+ 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, "'");
+}
+
+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, " ");
+}
+
+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: `#${pr.number} `,
+ 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,
+};
diff --git a/.github/scripts/preserve-release-artifacts.mjs b/.github/scripts/preserve-release-artifacts.mjs
new file mode 100644
index 0000000..e64cfe9
--- /dev/null
+++ b/.github/scripts/preserve-release-artifacts.mjs
@@ -0,0 +1,79 @@
+// 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}`);
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dd4f908
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,37 @@
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+name: Lint and Test
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Lint/Test
+ runs-on: macos-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
+
+ - run: vp install
+ - run: npm run bootstrap
+ - run: npm run lint
+ - 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
diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml
new file mode 100644
index 0000000..0567c9f
--- /dev/null
+++ b/.github/workflows/contribution-policy.yml
@@ -0,0 +1,48 @@
+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 });
diff --git a/.github/workflows/flathub.yml b/.github/workflows/flathub.yml
new file mode 100644
index 0000000..ce015ba
--- /dev/null
+++ b/.github/workflows/flathub.yml
@@ -0,0 +1,56 @@
+name: Update Flathub
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag to publish to Flathub
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ update-flathub:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Update Flathub manifest
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout app repo
+ uses: actions/checkout@v4
+
+ - name: Checkout Flathub repo
+ uses: actions/checkout@v4
+ with:
+ repository: flathub/app.yaak.Yaak
+ token: ${{ secrets.FLATHUB_TOKEN }}
+ path: flathub-repo
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+
+ - name: Install source generators
+ run: |
+ pip install flatpak-node-generator tomlkit aiohttp
+ 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
+
+ - name: Commit and push to Flathub
+ working-directory: flathub-repo
+ run: |
+ git config user.name "github-actions[bot]"
+ 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 push
diff --git a/.github/workflows/release-api-npm.yml b/.github/workflows/release-api-npm.yml
new file mode 100644
index 0000000..cc0f5c3
--- /dev/null
+++ b/.github/workflows/release-api-npm.yml
@@ -0,0 +1,60 @@
+name: Release API to NPM
+
+on:
+ push:
+ tags: [yaak-api-*]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: API version to publish (for example 0.9.0 or v0.9.0)
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ publish-npm:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Publish @yaakapp/api
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ registry-url: https://registry.npmjs.org
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Set @yaakapp/api version
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME#yaak-api-}"
+ fi
+ VERSION="${VERSION#v}"
+ echo "Preparing @yaakapp/api version: $VERSION"
+ cd packages/plugin-runtime-types
+ npm version "$VERSION" --no-git-tag-version --allow-same-version
+
+ - name: Build @yaakapp/api
+ working-directory: packages/plugin-runtime-types
+ run: npm run build
+
+ - name: Publish @yaakapp/api
+ working-directory: packages/plugin-runtime-types
+ run: npm publish --provenance --access public
diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml
new file mode 100644
index 0000000..a9bb6c4
--- /dev/null
+++ b/.github/workflows/release-app.yml
@@ -0,0 +1,292 @@
+name: Release App Artifacts
+on:
+ push:
+ tags: [v*]
+
+jobs:
+ build-artifacts:
+ if: github.repository == 'mountain-loop/yaak'
+ permissions:
+ contents: write
+
+ name: Build
+ strategy:
+ fail-fast: false
+ 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"]}}'''
+ 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"]}}'''
+ 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"]}}'''
+ 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"]}}'''
+ 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"]}}'''
+ 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"]}}'''
+ yaak_arch: "arm64"
+ os: "windows"
+ runtime: "wry"
+ targets: "aarch64-pc-windows-msvc"
+ runs-on: ${{ matrix.platform }}
+ timeout-minutes: 360
+ 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.targets }}
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ 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"
+
+ - name: Install Protoc for plugin-runtime
+ uses: arduino/setup-protoc@v3
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Install trusted-signing-cli (Windows only)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $dir = "$env:USERPROFILE\trusted-signing"
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ $url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
+ $exe = Join-Path $dir "trusted-signing-cli.exe"
+ Invoke-WebRequest -Uri $url -OutFile $exe
+ echo $dir >> $env:GITHUB_PATH
+ & $exe --version
+
+ - run: vp install
+ - run: npm run bootstrap
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+ - run: npm run lint
+ - name: Run JS Tests
+ run: vp test
+ - name: Run Rust Tests
+ run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
+
+ - name: Set version
+ run: npm run replace-version
+ env:
+ YAAK_VERSION: ${{ github.ref_name }}
+
+ - name: Sign vendored binaries (macOS only)
+ if: matrix.os == 'macos'
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ run: |
+ # Create keychain
+ 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
+
+ # Import certificate
+ echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
+ security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
+ 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
+
+ - name: Build and upload Tauri bundles
+ uses: tauri-apps/tauri-action@v0
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+
+ ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+
+ # Apple signing stuff
+ APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
+
+ # Windows signing stuff
+ AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
+ 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: ""
+ 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
+
+ # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
+ - name: Build and upload machine-wide installer (Windows only)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+ 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
+ $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
diff --git a/.github/workflows/release-cli-npm.yml b/.github/workflows/release-cli-npm.yml
new file mode 100644
index 0000000..8c45739
--- /dev/null
+++ b/.github/workflows/release-cli-npm.yml
@@ -0,0 +1,219 @@
+name: Release CLI to NPM
+
+on:
+ push:
+ tags: [v*]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: CLI version to publish (for example 0.4.0 or v0.4.0)
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ prepare-vendored-assets:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Prepare vendored plugin assets
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build plugin assets
+ env:
+ SKIP_WASM_BUILD: "1"
+ run: |
+ npm run build
+ npm run vendor:vendor-plugins
+
+ - name: Upload vendored assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: vendored-assets
+ path: |
+ crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
+ crates-tauri/yaak-app-client/vendored/plugins
+ if-no-files-found: error
+
+ build-binaries:
+ name: Build ${{ matrix.pkg }}
+ needs: prepare-vendored-assets
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - pkg: cli-darwin-arm64
+ runner: macos-latest
+ target: aarch64-apple-darwin
+ binary: yaak
+ - pkg: cli-darwin-x64
+ runner: macos-latest
+ target: x86_64-apple-darwin
+ binary: yaak
+ - pkg: cli-linux-arm64
+ runner: ubuntu-22.04-arm
+ target: aarch64-unknown-linux-gnu
+ binary: yaak
+ - pkg: cli-linux-x64
+ runner: ubuntu-22.04
+ target: x86_64-unknown-linux-gnu
+ binary: yaak
+ - pkg: cli-win32-arm64
+ runner: windows-latest
+ target: aarch64-pc-windows-msvc
+ binary: yaak.exe
+ - pkg: cli-win32-x64
+ runner: windows-latest
+ target: x86_64-pc-windows-msvc
+ binary: yaak.exe
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Restore Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: release-cli-npm
+ cache-on-failure: true
+
+ - name: Install Linux build dependencies
+ if: startsWith(matrix.runner, 'ubuntu')
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y pkg-config libdbus-1-dev
+
+ - name: Download vendored assets
+ uses: actions/download-artifact@v4
+ with:
+ name: vendored-assets
+ path: crates-tauri/yaak-app-client/vendored
+
+ - name: Set CLI build version
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME}"
+ fi
+ VERSION="${VERSION#v}"
+ echo "Building yaak version: $VERSION"
+ echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
+
+ - name: Build yaak
+ run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
+
+ - name: Stage binary artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p "npm/dist/${{ matrix.pkg }}"
+ cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
+
+ - name: Upload binary artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.pkg }}
+ path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
+ if-no-files-found: error
+
+ publish-npm:
+ name: Publish @yaakapp/cli packages
+ needs: build-binaries
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ registry-url: https://registry.npmjs.org
+
+ - name: Download binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: cli-*
+ path: npm/dist
+ merge-multiple: false
+
+ - name: Prepare npm packages
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME}"
+ fi
+ VERSION="${VERSION#v}"
+ if [[ "$VERSION" == *-* ]]; then
+ PRERELEASE="${VERSION#*-}"
+ NPM_TAG="${PRERELEASE%%.*}"
+ else
+ NPM_TAG="latest"
+ fi
+ echo "Preparing CLI npm packages for version: $VERSION"
+ echo "Publishing with npm dist-tag: $NPM_TAG"
+ echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
+ YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
+
+ - name: Publish @yaakapp/cli-darwin-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-darwin-arm64
+
+ - name: Publish @yaakapp/cli-darwin-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-darwin-x64
+
+ - name: Publish @yaakapp/cli-linux-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-linux-arm64
+
+ - name: Publish @yaakapp/cli-linux-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-linux-x64
+
+ - name: Publish @yaakapp/cli-win32-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-win32-arm64
+
+ - name: Publish @yaakapp/cli-win32-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-win32-x64
+
+ - name: Publish @yaakapp/cli
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli
diff --git a/.github/workflows/release-web-image.yml b/.github/workflows/release-web-image.yml
new file mode 100644
index 0000000..eee30e0
--- /dev/null
+++ b/.github/workflows/release-web-image.yml
@@ -0,0 +1,123 @@
+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 }}
diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml
new file mode 100644
index 0000000..a68a005
--- /dev/null
+++ b/.github/workflows/sponsors.yml
@@ -0,0 +1,45 @@
+name: Generate Sponsors README
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: 30 15 * * 0-6
+permissions:
+ contents: write
+jobs:
+ deploy:
+ if: github.repository == 'mountain-loop/yaak'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout 🛎️
+ uses: actions/checkout@v2
+
+ - name: Generate Sponsors
+ uses: JamesIves/github-sponsors-readme-action@v1
+ with:
+ token: ${{ secrets.SPONSORS_PAT }}
+ file: "README.md"
+ maximum: 1999
+ template: ' '
+ active-only: false
+ include-private: true
+ marker: "sponsors-base"
+
+ - name: Generate Sponsors
+ uses: JamesIves/github-sponsors-readme-action@v1
+ with:
+ token: ${{ secrets.SPONSORS_PAT }}
+ file: "README.md"
+ minimum: 2000
+ template: ' '
+ active-only: false
+ include-private: true
+ marker: "sponsors-premium"
+
+ # ⚠️ Note: You can use any deployment step here to automatically push the README
+ # changes back to your branch.
+ - name: Commit Changes
+ uses: JamesIves/github-pages-deploy-action@v4
+ with:
+ branch: main
+ force: false
+ folder: "."
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1beb88f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+!.vscode/launch.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.eslintcache
+out
+
+*.sqlite
+*.sqlite-*
+
+.cargo
+
+.tmp
+tmp
+.zed
+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
+
+# Tauri auto-generated permission files
+**/permissions/autogenerated
+**/permissions/schemas
+
+# Flatpak build artifacts
+flatpak-repo/
+.flatpak-builder/
+flatpak/flatpak-builder-tools/
+flatpak/cargo-sources.json
+flatpak/node-sources.json
+
+# Local Codex desktop env state
+.codex/environments/environment.toml
+
+# Claude Code local settings
+.claude/settings.local.json
+.claude/worktrees/
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..d845d9d
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+24.14.0
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..dc68961
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
+legacy-peer-deps=true
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 0000000..682f3b9
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,8 @@
+{
+ "printWidth": 100,
+ "ignorePatterns": [
+ "**/bindings/**",
+ "crates/yaak-templates/pkg/**",
+ "apps/yaak-client/routeTree.gen.ts"
+ ]
+}
diff --git a/.vite-hooks/post-checkout b/.vite-hooks/post-checkout
new file mode 100755
index 0000000..8ca7227
--- /dev/null
+++ b/.vite-hooks/post-checkout
@@ -0,0 +1 @@
+node scripts/git-hooks/post-checkout.mjs "$@"
diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit
new file mode 100644
index 0000000..e0686b6
--- /dev/null
+++ b/.vite-hooks/pre-commit
@@ -0,0 +1,2 @@
+vp lint
+vp staged
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..b2b5eb7
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "rust-lang.rust-analyzer",
+ "bradlc.vscode-tailwindcss",
+ "VoidZero.vite-plus-extension-pack"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..daca8f7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Dev App",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "start"]
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Build App",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "start"]
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Bootstrap",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "bootstrap"]
+ }
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b806535
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,8 @@
+{
+ "editor.defaultFormatter": "oxc.oxc-vscode",
+ "editor.formatOnSave": true,
+ "editor.formatOnSaveMode": "file",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.oxc": "explicit"
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f0ed185
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +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.
+- Do not commit, push, or tag without explicit approval
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d8b23a8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,15 @@
+# Contributing to Yaak
+
+Yaak accepts community pull requests for:
+
+- Bug fixes
+
+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.
+
+## Development Setup
+
+For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..1e877ca
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,118 @@
+[workspace]
+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-git",
+ "crates/yaak-grpc",
+ "crates/yaak-http",
+ "crates/yaak-lifecycle",
+ "crates/yaak-models",
+ "crates/yaak-plugins",
+ "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-fonts",
+ "crates-tauri/yaak-license",
+ "crates-tauri/yaak-mac-window",
+ "crates-tauri/yaak-system-appearance",
+ "crates-tauri/yaak-tauri-utils",
+ "crates-tauri/yaak-window",
+]
+
+[workspace.dependencies]
+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",
+] }
+rustls = { version = "0.23.34", default-features = false }
+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"
+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-git = { path = "crates/yaak-git" }
+yaak-grpc = { path = "crates/yaak-grpc" }
+yaak-http = { path = "crates/yaak-http" }
+yaak-lifecycle = { path = "crates/yaak-lifecycle" }
+yaak-models = { path = "crates/yaak-models" }
+yaak-plugins = { path = "crates/yaak-plugins" }
+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-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
+yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
+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" }
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..df43cae
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,105 @@
+# Developer Setup
+
+Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
+uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
+by a Node.js sidecar that communicates to the app over gRPC.
+
+Because of the moving parts, there are a few setup steps required before development can
+begin.
+
+## Prerequisites
+
+Make sure you have the following tools installed:
+
+- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
+- [Rust](https://www.rust-lang.org/tools/install)
+- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
+
+Check the installations with the following commands:
+
+```shell
+node -v
+npm -v
+vp --version
+rustc --version
+```
+
+Install the NPM dependencies:
+
+```shell
+npm install
+```
+
+Run the `bootstrap` command to do some initial setup:
+
+```shell
+npm run bootstrap
+```
+
+## Run the App
+
+After bootstrapping, start the app in development mode:
+
+```shell
+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:
+
+```shell
+npm run migration
+```
+
+Rerun the app to apply the migrations.
+
+_Note: For safety, development builds use a separate database location from production builds._
+
+## Lezer Grammar Generation
+
+```sh
+# Example
+lezer-generator components/core/Editor//.grammar > components/core/Editor//.ts
+```
+
+## Linting and Formatting
+
+This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
+
+- Lint the entire repo:
+
+```sh
+npm run lint
+```
+
+- Format code:
+
+```sh
+npm run format
+```
+
+Notes:
+
+- A pre-commit hook runs `vp lint` automatically on commit.
+- Some workspace packages also run `tsc --noEmit` for type-checking.
+- VS Code users should install the recommended extensions for format-on-save support.
diff --git a/Dockerfile.playground b/Dockerfile.playground
new file mode 100644
index 0000000..e631eb1
--- /dev/null
+++ b/Dockerfile.playground
@@ -0,0 +1,15 @@
+# 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"]
diff --git a/Dockerfile.web b/Dockerfile.web
new file mode 100644
index 0000000..460b5a1
--- /dev/null
+++ b/Dockerfile.web
@@ -0,0 +1,49 @@
+# 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"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ed6c1c3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Yaak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7778b21
--- /dev/null
+++ b/README.md
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+ 💫 Yaak ➟ Desktop API Client 💫
+
+
+
+ A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.
+
+
+ Development is funded by community-purchased licenses . You can also become a sponsor to have your logo appear below. 💖
+
+
+
+
+
+
+
+
+
+
+
+
+## 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.
+Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
+
+### 🌐 Work with any API
+
+- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
+- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
+- Filter and inspect responses with JSONPath or XPath.
+
+### 🔐 Stay secure
+
+- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
+- Secure sensitive values with encrypted secrets.
+- Store secrets in your OS keychain.
+
+### ☁️ Organize & collaborate
+
+- Group requests into workspaces and nested folders.
+- Use environment variables to switch between dev, staging, and prod.
+- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
+
+### 🧩 Extend & customize
+
+- Insert dynamic values like UUIDs or timestamps with template tags.
+- Pick from built-in themes or build your own.
+- Create plugins to extend authentication, template tags, or the UI.
+
+## 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.
+> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
+
+## Useful Resources
+
+- [Feedback and Bug Reports](https://feedback.yaak.app)
+- [Documentation](https://yaak.app/docs)
+- [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.
diff --git a/apps/yaak-client/.gitignore b/apps/yaak-client/.gitignore
new file mode 100644
index 0000000..371c82b
--- /dev/null
+++ b/apps/yaak-client/.gitignore
@@ -0,0 +1,2 @@
+vite.config.d.ts
+vite.config.js
diff --git a/apps/yaak-client/commands/commands.tsx b/apps/yaak-client/commands/commands.tsx
new file mode 100644
index 0000000..40618a7
--- /dev/null
+++ b/apps/yaak-client/commands/commands.tsx
@@ -0,0 +1,170 @@
+import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models";
+import { applySync, calculateSync } from "@yaakapp-internal/sync";
+import { Button } from "../components/core/Button";
+import {
+ Banner,
+ InlineCode,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ TruncatedWideTableCell,
+} from "@yaakapp-internal/ui";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { pluralizeCount } from "../lib/pluralize";
+import { showPrompt } from "../lib/prompt";
+import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
+
+export const createFolder = createFastMutation<
+ string | null,
+ void,
+ Partial>
+>({
+ mutationKey: ["create_folder"],
+ mutationFn: async (patch) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) {
+ throw new Error("Cannot create folder when there's no active workspace");
+ }
+
+ if (!patch.name) {
+ const name = await showPrompt({
+ id: "new-folder",
+ label: "Name",
+ defaultValue: "Folder",
+ title: "New Folder",
+ confirmText: "Create",
+ placeholder: "Name",
+ });
+ if (name == null) return null;
+
+ patch.name = name;
+ }
+
+ patch.sortPriority = patch.sortPriority || -Date.now();
+ const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
+ return id;
+ },
+});
+
+export const syncWorkspace = createFastMutation<
+ void,
+ void,
+ { workspaceId: string; syncDir: string; force?: boolean }
+>({
+ mutationKey: [],
+ mutationFn: async ({ workspaceId, syncDir, force }) => {
+ const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
+ if (ops.length === 0) {
+ console.log("Nothing to sync", workspaceId, syncDir);
+ return;
+ }
+ console.log("Syncing workspace", workspaceId, syncDir, ops);
+
+ const dbOps = ops.filter((o) => o.type.startsWith("db"));
+
+ if (dbOps.length === 0) {
+ await applySync(workspaceId, syncDir, ops);
+ return;
+ }
+
+ const isDeletingWorkspace = ops.some(
+ (o) => o.type === "dbDelete" && o.model.model === "workspace",
+ );
+
+ console.log("Directory changes detected", { dbOps, ops });
+
+ if (force) {
+ await applySync(workspaceId, syncDir, ops);
+ return;
+ }
+
+ showDialog({
+ id: "commit-sync",
+ title: "Changes Detected",
+ size: "md",
+ render: ({ hide }) => (
+
+ ),
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/createEnvironment.tsx b/apps/yaak-client/commands/createEnvironment.tsx
new file mode 100644
index 0000000..80f68e1
--- /dev/null
+++ b/apps/yaak-client/commands/createEnvironment.tsx
@@ -0,0 +1,51 @@
+import type { Environment } from "@yaakapp-internal/models";
+import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
+
+export const createSubEnvironmentAndActivate = createFastMutation<
+ string | null,
+ unknown,
+ Environment | null
+>({
+ mutationKey: ["create_environment"],
+ mutationFn: async (baseEnvironment) => {
+ if (baseEnvironment == null) {
+ throw new Error("No base environment passed");
+ }
+
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) {
+ throw new Error("Cannot create environment when no active workspace");
+ }
+
+ return new Promise((resolve) => {
+ showDialog({
+ id: "new-environment",
+ title: "New Environment",
+ description: "Create multiple environments with different sets of variables",
+ size: "sm",
+ onClose: () => resolve(null),
+ render: ({ hide }) => (
+ {
+ resolve(id);
+ }}
+ />
+ ),
+ });
+ });
+ },
+ onSuccess: async (environmentId) => {
+ if (environmentId == null) {
+ return; // Was not created
+ }
+
+ setWorkspaceSearchParams({ environment_id: environmentId });
+ },
+});
diff --git a/apps/yaak-client/commands/deleteWebsocketConnections.ts b/apps/yaak-client/commands/deleteWebsocketConnections.ts
new file mode 100644
index 0000000..e2ce134
--- /dev/null
+++ b/apps/yaak-client/commands/deleteWebsocketConnections.ts
@@ -0,0 +1,8 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
+import { createFastMutation } from "../hooks/useFastMutation";
+
+export const deleteWebsocketConnections = createFastMutation({
+ mutationKey: ["delete_websocket_connections"],
+ mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
+});
diff --git a/apps/yaak-client/commands/importCurl.ts b/apps/yaak-client/commands/importCurl.ts
new file mode 100644
index 0000000..37973c3
--- /dev/null
+++ b/apps/yaak-client/commands/importCurl.ts
@@ -0,0 +1,52 @@
+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`,
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/moveToWorkspace.tsx b/apps/yaak-client/commands/moveToWorkspace.tsx
new file mode 100644
index 0000000..3428a58
--- /dev/null
+++ b/apps/yaak-client/commands/moveToWorkspace.tsx
@@ -0,0 +1,33 @@
+import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
+
+import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { pluralizeCount } from "../lib/pluralize";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+
+export const moveToWorkspace = createFastMutation({
+ mutationKey: ["move_workspace"],
+ mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
+ const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (activeWorkspaceId == null) return;
+ if (requests.length === 0) return;
+
+ const title =
+ requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
+
+ showDialog({
+ id: "change-workspace",
+ title,
+ size: "sm",
+ render: ({ hide }) => (
+
+ ),
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openFolderSettings.tsx b/apps/yaak-client/commands/openFolderSettings.tsx
new file mode 100644
index 0000000..e08ad57
--- /dev/null
+++ b/apps/yaak-client/commands/openFolderSettings.tsx
@@ -0,0 +1,17 @@
+import { getModel } from "@yaakapp-internal/models";
+import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
+import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
+import { showDialog } from "../lib/dialog";
+
+export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
+ const folder = getModel("folder", folderId);
+ if (folder == null) return;
+ showDialog({
+ id: "folder-settings",
+ title: null,
+ size: "lg",
+ className: "h-200",
+ noPadding: true,
+ render: () => ,
+ });
+}
diff --git a/apps/yaak-client/commands/openSettings.tsx b/apps/yaak-client/commands/openSettings.tsx
new file mode 100644
index 0000000..7d7e103
--- /dev/null
+++ b/apps/yaak-client/commands/openSettings.tsx
@@ -0,0 +1,48 @@
+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({
+ 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 }) => ,
+ });
+ 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],
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
new file mode 100644
index 0000000..9c5b6d2
--- /dev/null
+++ b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
@@ -0,0 +1,27 @@
+import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showSimpleAlert } from "../lib/alert";
+import { router } from "../lib/router";
+
+export const openWorkspaceFromSyncDir = createFastMutation({
+ mutationKey: [],
+ mutationFn: async (dir) => {
+ const ops = await calculateSyncFsOnly(dir);
+
+ const workspace = ops
+ .map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
+ .filter((m) => m)[0];
+
+ if (workspace == null) {
+ showSimpleAlert("Failed to Open", "No workspace found in directory");
+ return;
+ }
+
+ await applySync(workspace.id, dir, ops);
+
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId: workspace.id },
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openWorkspaceSettings.tsx b/apps/yaak-client/commands/openWorkspaceSettings.tsx
new file mode 100644
index 0000000..1af368f
--- /dev/null
+++ b/apps/yaak-client/commands/openWorkspaceSettings.tsx
@@ -0,0 +1,10 @@
+import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
+import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { jotaiStore } from "../lib/jotai";
+
+export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) return;
+ WorkspaceSettingsDialog.show(workspaceId, tab);
+}
diff --git a/apps/yaak-client/commands/switchWorkspace.tsx b/apps/yaak-client/commands/switchWorkspace.tsx
new file mode 100644
index 0000000..9891d57
--- /dev/null
+++ b/apps/yaak-client/commands/switchWorkspace.tsx
@@ -0,0 +1,46 @@
+import { platform } from "@yaakapp-internal/platform";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
+import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { getRecentRequests } from "../hooks/useRecentRequests";
+import { router } from "../lib/router";
+import { rpc } from "../lib/rpc";
+
+export const switchWorkspace = createFastMutation<
+ void,
+ unknown,
+ {
+ workspaceId: string;
+ inNewWindow: boolean;
+ }
+>({
+ mutationKey: ["open_workspace"],
+ mutationFn: async ({ workspaceId, inNewWindow }) => {
+ const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
+ const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
+ const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
+ const search = {
+ environment_id: environmentId,
+ cookie_jar_id: cookieJarId,
+ request_id: requestId,
+ };
+
+ // A host without windows opens the workspace here instead. Refusing would
+ // strand the user on the workspace they were trying to leave.
+ if (inNewWindow && platform.capabilities.multiWindow) {
+ const location = router.buildLocation({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId },
+ search,
+ });
+ await rpc("cmd_new_main_window", { url: location.href });
+ return;
+ }
+
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId },
+ search,
+ });
+ },
+});
diff --git a/apps/yaak-client/components/BinaryFileEditor.tsx b/apps/yaak-client/components/BinaryFileEditor.tsx
new file mode 100644
index 0000000..ccab5fa
--- /dev/null
+++ b/apps/yaak-client/components/BinaryFileEditor.tsx
@@ -0,0 +1,63 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
+import mime from "mime";
+import { useKeyValue } from "../hooks/useKeyValue";
+import { Button } from "./core/Button";
+import { SelectFile } from "./SelectFile";
+
+type Props = {
+ requestId: string;
+ contentType: string | null;
+ body: HttpRequest["body"];
+ onChange: (body: HttpRequest["body"]) => void;
+ onChangeContentType: (contentType: string | null) => void;
+};
+
+export function BinaryFileEditor({
+ contentType,
+ body,
+ onChange,
+ onChangeContentType,
+ requestId,
+}: Props) {
+ const ignoreContentType = useKeyValue({
+ namespace: "global",
+ key: ["ignore_content_type", requestId],
+ fallback: false,
+ });
+
+ const handleChange = async ({ filePath }: { filePath: string | null }) => {
+ await ignoreContentType.set(false);
+ onChange({ filePath: filePath ?? undefined });
+ };
+
+ const filePath = typeof body.filePath === "string" ? body.filePath : null;
+ const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
+
+ return (
+
+
+ {filePath != null && mimeType !== contentType && !ignoreContentType.value && (
+
+
+
Set Content-Type header
+
{mimeType} for current request?
+
+
+ ignoreContentType.set(true)}>
+ Ignore
+
+ onChangeContentType(mimeType)}
+ >
+ Set Header
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/CargoFeature.tsx b/apps/yaak-client/components/CargoFeature.tsx
new file mode 100644
index 0000000..7a5d1fe
--- /dev/null
+++ b/apps/yaak-client/components/CargoFeature.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+import { appInfo } from "../lib/appInfo";
+
+interface Props {
+ children: ReactNode;
+ feature: "updater" | "license";
+}
+
+const featureMap: Record = {
+ updater: appInfo.featureUpdater,
+ license: appInfo.featureLicense,
+};
+
+export function CargoFeature({ children, feature }: Props) {
+ if (featureMap[feature]) {
+ return <>{children}>;
+ }
+ return null;
+}
diff --git a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx
new file mode 100644
index 0000000..1602110
--- /dev/null
+++ b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx
@@ -0,0 +1,163 @@
+import { gitClone } from "@yaakapp-internal/git";
+import { Banner, VStack } from "@yaakapp-internal/ui";
+import { useState } from "react";
+import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
+import { appInfo } from "../lib/appInfo";
+import { CommercialUseBanner } from "./CommercialUseBanner";
+import { showErrorToast } from "../lib/toast";
+import { Button } from "./core/Button";
+import { Checkbox } from "./core/Checkbox";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import { promptCredentials } from "./git/credentials";
+import { platform } from "@yaakapp-internal/platform";
+
+interface Props {
+ hide: () => void;
+}
+
+// Detect path separator from an existing path (defaults to /)
+function getPathSeparator(path: string): string {
+ return path.includes("\\") ? "\\" : "/";
+}
+
+export function CloneGitRepositoryDialog({ hide }: Props) {
+ const [url, setUrl] = useState("");
+ const [baseDirectory, setBaseDirectory] = useState(appInfo.defaultProjectDir);
+ const [directoryOverride, setDirectoryOverride] = useState(null);
+ const [hasSubdirectory, setHasSubdirectory] = useState(false);
+ const [subdirectory, setSubdirectory] = useState("");
+ const [isCloning, setIsCloning] = useState(false);
+ const [error, setError] = useState(null);
+
+ const repoName = extractRepoName(url);
+ const sep = getPathSeparator(baseDirectory);
+ const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
+ const directory = directoryOverride ?? computedDirectory;
+ const workspaceDirectory =
+ hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
+
+ const handleSelectDirectory = async () => {
+ const dir = await platform.dialog.open({
+ title: "Select Directory",
+ directory: true,
+ multiple: false,
+ });
+ if (dir != null) {
+ setBaseDirectory(dir);
+ setDirectoryOverride(null);
+ }
+ };
+
+ const handleClone = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!url || !directory) return;
+
+ setIsCloning(true);
+ setError(null);
+
+ try {
+ const result = await gitClone(url, directory, promptCredentials);
+
+ if (result.type === "needs_credentials") {
+ setError(
+ result.error ?? "Authentication failed. Please check your credentials and try again.",
+ );
+ return;
+ }
+
+ // Open the workspace from the cloned directory (or subdirectory)
+ await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
+
+ hide();
+ } catch (err) {
+ setError(String(err));
+ showErrorToast({
+ id: "git-clone-error",
+ title: "Clone Failed",
+ message: String(err),
+ });
+ } finally {
+ setIsCloning(false);
+ }
+ };
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {hasSubdirectory && (
+
+ )}
+
+
+ {isCloning ? "Cloning..." : "Clone Repository"}
+
+
+ );
+}
+
+function extractRepoName(url: string): string {
+ // Handle various Git URL formats:
+ // https://github.com/user/repo.git
+ // git@github.com:user/repo.git
+ // https://github.com/user/repo
+ const match = url.match(/\/([^/]+?)(\.git)?$/);
+ if (match?.[1]) {
+ return match[1];
+ }
+ // Fallback for SSH-style URLs
+ const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
+ if (sshMatch?.[1]) {
+ return sshMatch[1];
+ }
+ return "";
+}
diff --git a/apps/yaak-client/components/ColorIndicator.tsx b/apps/yaak-client/components/ColorIndicator.tsx
new file mode 100644
index 0000000..c5ed3a1
--- /dev/null
+++ b/apps/yaak-client/components/ColorIndicator.tsx
@@ -0,0 +1,28 @@
+import classNames from "classnames";
+import type { CSSProperties } from "react";
+
+interface Props {
+ color: string | null;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function ColorIndicator({ color, onClick, className }: Props) {
+ const style: CSSProperties = { backgroundColor: color ?? undefined };
+ const finalClassName = classNames(
+ className,
+ "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
+ );
+
+ if (onClick) {
+ return (
+
+ );
+ }
+ return ;
+}
diff --git a/apps/yaak-client/components/CommandPaletteDialog.tsx b/apps/yaak-client/components/CommandPaletteDialog.tsx
new file mode 100644
index 0000000..f6f3359
--- /dev/null
+++ b/apps/yaak-client/components/CommandPaletteDialog.tsx
@@ -0,0 +1,507 @@
+import { workspacesAtom } from "@yaakapp-internal/models";
+import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { fuzzyFilter } from "fuzzbunny";
+import { useAtomValue } from "jotai";
+import {
+ Fragment,
+ type KeyboardEvent,
+ type ReactNode,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { createFolder } from "../commands/commands";
+import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
+import { openSettings } from "../commands/openSettings";
+import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
+import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
+import { useActiveRequest } from "../hooks/useActiveRequest";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { useAllRequests } from "../hooks/useAllRequests";
+import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
+import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
+import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
+import type { HotkeyAction } from "../hooks/useHotKey";
+import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
+import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { useRecentRequests } from "../hooks/useRecentRequests";
+import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
+import { useScrollIntoView } from "../hooks/useScrollIntoView";
+import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { appInfo } from "../lib/appInfo";
+import { copyToClipboard } from "../lib/copy";
+import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+import { editEnvironment } from "../lib/editEnvironment";
+import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
+import {
+ resolvedModelNameWithFolders,
+ resolvedModelNameWithFoldersArray,
+} from "../lib/resolvedModelName";
+import { router } from "../lib/router";
+import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
+import { CookieDialog } from "./CookieDialog";
+import { Button } from "./core/Button";
+import { Hotkey } from "./core/Hotkey";
+import { HttpMethodTag } from "./core/HttpMethodTag";
+import { PlainInput } from "./core/PlainInput";
+
+interface CommandPaletteGroup {
+ key: string;
+ label: ReactNode;
+ items: CommandPaletteItem[];
+}
+
+type CommandPaletteItem = {
+ key: string;
+ onSelect: () => void;
+ action?: HotkeyAction;
+} & ({ searchText: string; label: ReactNode } | { label: string });
+
+const MAX_PER_GROUP = 8;
+
+export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
+ const [command, setCommand] = useDebouncedState("", 150);
+ const [selectedItemKey, setSelectedItemKey] = useState(null);
+ const activeEnvironment = useActiveEnvironment();
+ const httpRequestActions = useHttpRequestActions();
+ const grpcRequestActions = useGrpcRequestActions();
+ const workspaceId = useAtomValue(activeWorkspaceIdAtom);
+ const workspaces = useAtomValue(workspacesAtom);
+ const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
+ const createWorkspace = useCreateWorkspace();
+ const recentEnvironments = useRecentEnvironments();
+ const recentWorkspaces = useRecentWorkspaces();
+ const requests = useAllRequests();
+ const activeRequest = useActiveRequest();
+ const activeCookieJar = useActiveCookieJar();
+ const [recentRequests] = useRecentRequests();
+ const [, setSidebarHidden] = useSidebarHidden();
+ const { mutate: sendRequest } = useSendAnyHttpRequest();
+
+ const handleSetCommand = (command: string) => {
+ setCommand(command);
+ setSelectedItemKey(null);
+ };
+
+ const workspaceCommands = useMemo(() => {
+ if (workspaceId == null) return [];
+
+ const commands: CommandPaletteItem[] = [
+ {
+ key: "settings.open",
+ label: "Open Settings",
+ action: "settings.show",
+ onSelect: () => openSettings.mutate(null),
+ },
+ {
+ key: "workspace_settings.open",
+ label: "Open Workspace Settings",
+ action: "workspace_settings.show",
+ onSelect: () => openWorkspaceSettings(),
+ },
+ {
+ key: "app.create",
+ label: "Create Workspace",
+ onSelect: createWorkspace,
+ },
+ {
+ key: "model.create",
+ label: "Create HTTP Request",
+ onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
+ },
+ {
+ key: "grpc_request.create",
+ label: "Create GRPC Request",
+ onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
+ },
+ {
+ key: "websocket_request.create",
+ label: "Create Websocket Request",
+ onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
+ },
+ {
+ key: "folder.create",
+ label: "Create Folder",
+ onSelect: () => createFolder.mutate({}),
+ },
+ {
+ key: "cookies.show",
+ label: "Show Cookies",
+ action: "cookies_editor.show",
+ onSelect: async () => {
+ CookieDialog.show(activeCookieJar?.id ?? null);
+ },
+ },
+ {
+ key: "environment.edit",
+ label: "Edit Environment",
+ action: "environment_editor.toggle",
+ onSelect: () => editEnvironment(activeEnvironment),
+ },
+ {
+ key: "environment.create",
+ label: "Create Environment",
+ onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
+ },
+ {
+ key: "sidebar.toggle",
+ label: "Toggle Sidebar",
+ action: "sidebar.focus",
+ onSelect: () => setSidebarHidden((h) => !h),
+ },
+ ];
+
+ if (activeRequest?.model === "http_request") {
+ commands.push({
+ key: "request.send",
+ action: "request.send",
+ label: "Send Request",
+ onSelect: () => sendRequest(activeRequest.id),
+ });
+ if (appInfo.cliVersion != null) {
+ commands.push({
+ key: "request.copy_cli_send",
+ searchText: `copy cli send yaak request send ${activeRequest.id}`,
+ label: "Copy CLI Send Command",
+ onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
+ });
+ }
+ httpRequestActions.forEach((a, i) => {
+ commands.push({
+ key: `http_request_action.${i}`,
+ label: a.label,
+ onSelect: () => a.call(activeRequest),
+ });
+ });
+ }
+
+ if (activeRequest?.model === "grpc_request") {
+ grpcRequestActions.forEach((a, i) => {
+ commands.push({
+ key: `grpc_request_action.${i}`,
+ label: a.label,
+ onSelect: () => a.call(activeRequest),
+ });
+ });
+ }
+
+ if (activeRequest != null) {
+ commands.push({
+ key: "http_request.rename",
+ label: "Rename Request",
+ onSelect: () => renameModelWithPrompt(activeRequest),
+ });
+
+ commands.push({
+ key: "sidebar.selected.delete",
+ label: "Delete Request",
+ onSelect: () => deleteModelWithConfirm(activeRequest),
+ });
+ }
+
+ return commands.sort((a, b) =>
+ ("searchText" in a ? a.searchText : a.label).localeCompare(
+ "searchText" in b ? b.searchText : b.label,
+ ),
+ );
+ }, [
+ activeCookieJar?.id,
+ activeEnvironment,
+ activeRequest,
+ baseEnvironment,
+ createWorkspace,
+ grpcRequestActions,
+ httpRequestActions,
+ sendRequest,
+ setSidebarHidden,
+ workspaceId,
+ ]);
+
+ const sortedRequests = useMemo(() => {
+ return [...requests].sort((a, b) => {
+ const aRecentIndex = recentRequests.indexOf(a.id);
+ const bRecentIndex = recentRequests.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [recentRequests, requests]);
+
+ const sortedEnvironments = useMemo(() => {
+ return [...subEnvironments].sort((a, b) => {
+ const aRecentIndex = recentEnvironments.indexOf(a.id);
+ const bRecentIndex = recentEnvironments.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [subEnvironments, recentEnvironments]);
+
+ const sortedWorkspaces = useMemo(() => {
+ if (recentWorkspaces == null) {
+ // Should never happen
+ return workspaces;
+ }
+
+ return [...workspaces].sort((a, b) => {
+ const aRecentIndex = recentWorkspaces?.indexOf(a.id);
+ const bRecentIndex = recentWorkspaces?.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [recentWorkspaces, workspaces]);
+
+ const groups = useMemo(() => {
+ const actionsGroup: CommandPaletteGroup = {
+ key: "actions",
+ label: "Actions",
+ items: workspaceCommands,
+ };
+
+ const requestGroup: CommandPaletteGroup = {
+ key: "requests",
+ label: "Switch Request",
+ items: [],
+ };
+
+ for (const r of sortedRequests) {
+ requestGroup.items.push({
+ key: `switch-request-${r.id}`,
+ searchText: resolvedModelNameWithFolders(r),
+ label: (
+
+
+ {resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
+
+ {i !== 0 && }
+ {name}
+
+ ))}
+
+ ),
+ onSelect: async () => {
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId: r.workspaceId },
+ search: (prev) => ({ ...prev, request_id: r.id }),
+ });
+ },
+ });
+ }
+
+ const environmentGroup: CommandPaletteGroup = {
+ key: "environments",
+ label: "Switch Environment",
+ items: [],
+ };
+
+ for (const e of sortedEnvironments) {
+ if (e.id === activeEnvironment?.id) {
+ continue;
+ }
+ environmentGroup.items.push({
+ key: `switch-environment-${e.id}`,
+ label: e.name,
+ onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),
+ });
+ }
+
+ const workspaceGroup: CommandPaletteGroup = {
+ key: "workspaces",
+ label: "Switch Workspace",
+ items: [],
+ };
+
+ for (const w of sortedWorkspaces) {
+ workspaceGroup.items.push({
+ key: `switch-workspace-${w.id}`,
+ label: w.name,
+ onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
+ });
+ }
+
+ return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
+ }, [
+ workspaceCommands,
+ sortedRequests,
+ sortedEnvironments,
+ activeEnvironment?.id,
+ sortedWorkspaces,
+ ]);
+
+ const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
+
+ const { filteredGroups, filteredAllItems } = useMemo(() => {
+ const result = command
+ ? fuzzyFilter(
+ allItems.map((i) => ({
+ ...i,
+ filterBy: "searchText" in i ? i.searchText : i.label,
+ })),
+ command,
+ { fields: ["filterBy"] },
+ )
+ .sort((a, b) => b.score - a.score)
+ .map((v) => v.item)
+ : allItems;
+
+ const filteredGroups = groups
+ .map((g) => {
+ const items = result
+ .filter((i) => g.items.find((i2) => i2.key === i.key))
+ .slice(0, MAX_PER_GROUP);
+ return { ...g, items };
+ })
+ .filter((g) => g.items.length > 0);
+
+ const filteredAllItems = filteredGroups.flatMap((g) => g.items);
+ return { filteredAllItems, filteredGroups };
+ }, [allItems, command, groups]);
+
+ const handleSelectAndClose = useCallback(
+ (cb: () => void) => {
+ onClose();
+ cb();
+ },
+ [onClose],
+ );
+
+ const selectedItem = useMemo(() => {
+ let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
+ if (selectedItem == null) {
+ selectedItem = filteredAllItems[0] ?? null;
+ }
+ return selectedItem;
+ }, [filteredAllItems, selectedItemKey]);
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
+ if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
+ const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
+ setSelectedItemKey(next?.key ?? null);
+ } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
+ const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
+ setSelectedItemKey(prev?.key ?? null);
+ } else if (e.key === "Enter") {
+ const selected = filteredAllItems[index];
+ setSelectedItemKey(selected?.key ?? null);
+ if (selected) {
+ handleSelectAndClose(selected.onSelect);
+ }
+ }
+ },
+ [filteredAllItems, handleSelectAndClose, selectedItem?.key],
+ );
+
+ return (
+
+
+ }
+ name="command"
+ label="Command"
+ placeholder="Search or type a command"
+ className="font-sans text-base!"
+ defaultValue={command}
+ onChange={handleSetCommand}
+ onKeyDownCapture={handleKeyDown}
+ />
+
+
+ {filteredGroups.map((g) => (
+
+
+ {g.label}
+
+ {g.items.map((v) => (
+ handleSelectAndClose(v.onSelect)}
+ rightSlot={v.action && }
+ >
+ {v.label}
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+function CommandPaletteItem({
+ children,
+ active,
+ onClick,
+ rightSlot,
+}: {
+ children: ReactNode;
+ active: boolean;
+ onClick: () => void;
+ rightSlot?: ReactNode;
+}) {
+ const ref = useRef(null);
+ useScrollIntoView(ref.current, active);
+
+ return (
+
+ {children}
+
+ );
+}
+
+function CommandPaletteAction({ action }: { action: HotkeyAction }) {
+ return ;
+}
diff --git a/apps/yaak-client/components/CommercialUseBanner.tsx b/apps/yaak-client/components/CommercialUseBanner.tsx
new file mode 100644
index 0000000..a69283d
--- /dev/null
+++ b/apps/yaak-client/components/CommercialUseBanner.tsx
@@ -0,0 +1,129 @@
+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({
+ 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 (
+
+
+ setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
+ }
+ onShow={handleShow}
+ actions={[
+ {
+ label: "Purchase License",
+ color: "info",
+ variant: "solid",
+ onClick: () => {
+ openCommercialUsePricing(source).catch(console.error);
+ },
+ },
+ ]}
+ >
+
+
{title}
+
{COMMERCIAL_USE_BANNER_MESSAGE}
+
+
+
+ );
+}
+
+async function shouldShowCommercialUsePrompt(): Promise {
+ // 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 {
+ 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;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx
new file mode 100644
index 0000000..f8acf53
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx
@@ -0,0 +1,74 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import { patchModel } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import type { ReactNode } from "react";
+import { useToggle } from "../hooks/useToggle";
+import { showConfirm } from "../lib/confirm";
+import { Button } from "./core/Button";
+import { Link } from "./core/Link";
+import { SizeTag } from "./core/SizeTag";
+
+interface Props {
+ children: ReactNode;
+ request: HttpRequest;
+}
+
+const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeRequestBody({ children, request }: Props) {
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+
+ if (request.body?.text == null) {
+ return children;
+ }
+
+ const contentLength = request.body.text.length ?? 0;
+ const tooLargeBytes = LARGE_TEXT_BYTES;
+ const isLarge = contentLength > tooLargeBytes;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Rendering content over{" "}
+
+
+ {" "}
+ may impact performance.
+
+
+ See{" "}
+
+ Working With Large Values
+ {" "}
+ for tips.
+
+
+
+ Reveal Body
+
+ {
+ const confirm = await showConfirm({
+ id: `delete-body-${request.id}`,
+ confirmText: "Delete Body",
+ title: "Delete Body Text",
+ description: "Are you sure you want to delete the request body text?",
+ color: "danger",
+ });
+ if (confirm) {
+ await patchModel(request, { body: { ...request.body, text: "" } });
+ }
+ }}
+ >
+ Delete Body
+
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx
new file mode 100644
index 0000000..90bb2c8
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeResponse.tsx
@@ -0,0 +1,61 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { type ReactNode, useMemo } from "react";
+import { useSaveResponse } from "../hooks/useSaveResponse";
+import { useToggle } from "../hooks/useToggle";
+import { isProbablyTextContentType } from "../lib/contentType";
+import { getContentTypeFromHeaders } from "../lib/model_util";
+import { getResponseBodyText } from "../lib/responseBody";
+import { CopyButton } from "./CopyButton";
+import { Button } from "./core/Button";
+import { SizeTag } from "./core/SizeTag";
+
+interface Props {
+ children: ReactNode;
+ response: HttpResponse;
+}
+
+const LARGE_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeResponse({ children, response }: Props) {
+ const { mutate: saveResponse } = useSaveResponse(response);
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+ const isProbablyText = useMemo(() => {
+ const contentType = getContentTypeFromHeaders(response.headers);
+ return isProbablyTextContentType(contentType);
+ }, [response.headers]);
+
+ const contentLength = response.contentLength ?? 0;
+ const isLarge = contentLength > LARGE_BYTES;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Showing responses over{" "}
+
+
+ {" "}
+ may impact performance
+
+
+
+ Reveal Response
+
+ saveResponse()}>
+ Save to File
+
+ {isProbablyText && (
+ getResponseBodyText({ response, filter: null })}
+ />
+ )}
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
new file mode 100644
index 0000000..92525f7
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
@@ -0,0 +1,56 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { type ReactNode, useMemo } from "react";
+import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
+import { useToggle } from "../hooks/useToggle";
+import { isProbablyTextContentType } from "../lib/contentType";
+import { getContentTypeFromHeaders } from "../lib/model_util";
+import { CopyButton } from "./CopyButton";
+import { Button } from "./core/Button";
+import { SizeTag } from "./core/SizeTag";
+
+interface Props {
+ children: ReactNode;
+ response: HttpResponse;
+}
+
+const LARGE_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeResponseRequest({ children, response }: Props) {
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+ const isProbablyText = useMemo(() => {
+ const contentType = getContentTypeFromHeaders(response.headers);
+ return isProbablyTextContentType(contentType);
+ }, [response.headers]);
+
+ const contentLength = response.requestContentLength ?? 0;
+ const isLarge = contentLength > LARGE_BYTES;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Showing content over{" "}
+
+
+ {" "}
+ may impact performance
+
+
+
+ Reveal Request Body
+
+ {isProbablyText && (
+ getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
+ />
+ )}
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ContextMenus.tsx b/apps/yaak-client/components/ContextMenus.tsx
new file mode 100644
index 0000000..dd8bad8
--- /dev/null
+++ b/apps/yaak-client/components/ContextMenus.tsx
@@ -0,0 +1,26 @@
+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 }) => (
+
+ hideContextMenu(id)}
+ />
+
+ ))}
+ >
+ );
+}
diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx
new file mode 100644
index 0000000..31c9091
--- /dev/null
+++ b/apps/yaak-client/components/CookieDialog.tsx
@@ -0,0 +1,731 @@
+import type { Cookie } from "@yaakapp-internal/models";
+import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
+import { formatDate } from "date-fns/format";
+import { useAtomValue } from "jotai";
+import {
+ type ComponentProps,
+ type CSSProperties,
+ type FormEvent,
+ type ReactNode,
+ type RefObject,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { cookieDomain } from "../lib/model_util";
+import {
+ Icon,
+ SplitLayout,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ TruncatedWideTableCell,
+} from "@yaakapp-internal/ui";
+import { IconButton } from "./core/IconButton";
+import { Checkbox } from "./core/Checkbox";
+import classNames from "classnames";
+import { EventDetailHeader } from "./core/EventViewer";
+import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
+import { EmptyStateText } from "./EmptyStateText";
+import { PlainInput } from "./core/PlainInput";
+import { Select } from "./core/Select";
+import { showAlert } from "../lib/alert";
+
+interface Props {
+ cookieJarId: string | null;
+}
+
+export const CookieDialog = ({ cookieJarId }: Props) => {
+ const cookieJars = useAtomValue(cookieJarsAtom);
+ const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
+ const [filter, setFilter] = useState("");
+ const [filterUpdateKey, setFilterUpdateKey] = useState(0);
+ const [selectedCookieKey, setSelectedCookieKey] = useState(null);
+ const [editingCookieKey, setEditingCookieKey] = useState(null);
+ const [draftCookie, setDraftCookie] = useState(null);
+ const [draftExpiresInput, setDraftExpiresInput] = useState("");
+ const editorFormRef = useRef(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) => {
+ 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 No cookie jar selected
;
+ }
+
+ return (
+
+
+
0 && (
+ {
+ setFilter("");
+ setFilterUpdateKey((key) => key + 1);
+ }}
+ />
+ )
+ }
+ />
+
+
+ {cookieJar.cookies.length === 0 && detailCookie == null ? (
+
+ Cookies will appear when a response includes a Set-Cookie header.
+
+ ) : filteredCookies.length === 0 && detailCookie == null ? (
+
No cookies match the current filter.
+ ) : (
+
+ filteredCookies.length === 0 ? (
+
+ No cookies match the current filter.
+
+ ) : (
+
+
+
+ Name
+ Value
+ Domain
+ Path
+ Expires
+ Size
+ HTTP Only
+ Secure
+ Same Site
+
+ {
+ setSelectedCookieKey(null);
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ void patchModel(cookieJar, { cookies: [] });
+ }}
+ />
+
+
+
+
+ {filteredCookies.map((c: Cookie) => {
+ const key = cookieKey(c);
+ const isSelected = key === selectedCookieKey;
+
+ return (
+ {
+ setSelectedCookieKey(key);
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ }}
+ >
+
+ {c.name}
+
+
+ {c.value}
+
+ {cookieDomain(c)}
+ {c.path}
+ {cookieExpires(c)}
+ {cookieSize(c)}
+
+
+
+
+
+
+ {c.sameSite}
+
+ {
+ 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,
+ ),
+ });
+ }}
+ />
+
+
+ );
+ })}
+
+
+ )
+ }
+ secondSlot={
+ detailCookie == null
+ ? null
+ : ({ style }) => (
+
+ editorFormRef.current?.requestSubmit(),
+ },
+ {
+ key: "cancel",
+ label: "Cancel",
+ onClick: handleCancelEdit,
+ },
+ ]
+ : [
+ {
+ key: "edit",
+ label: "Edit",
+ onClick: handleEditCookie,
+ },
+ ]
+ }
+ onClose={handleCloseDetails}
+ />
+ {isEditingCookie ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ />
+ )}
+
+ );
+};
+
+function CookieDetailsPane({
+ children,
+ formRef,
+ isEditing,
+ onSubmit,
+ style,
+}: {
+ children: ReactNode;
+ formRef: RefObject;
+ isEditing: boolean;
+ onSubmit: (event: FormEvent) => void;
+ style: CSSProperties;
+}) {
+ const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+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: () => ,
+ });
+};
+
+function CookieDetails({ cookie }: { cookie: Cookie }) {
+ return (
+
+
+ {cookie.name}
+
+ {cookie.value}
+
+ {cookieDomain(cookie)}
+ {cookie.path}
+ {cookieExpires(cookie)}
+ {cookieSize(cookie)}
+ {cookie.httpOnly ? "Yes" : "No"}
+ {cookie.secure ? "Yes" : "No"}
+ {cookie.sameSite && (
+ {cookie.sameSite}
+ )}
+
+
+ );
+}
+
+function CookieEditor({
+ cookie,
+ expiresInputValue,
+ onChange,
+ onExpiresInputChange,
+}: {
+ cookie: Cookie;
+ expiresInputValue: string;
+ onChange: (cookie: Cookie) => void;
+ onExpiresInputChange: (value: string) => void;
+}) {
+ const sessionCookie = cookie.expires === "SessionEnd";
+
+ return (
+
+
+
+ onChange({ ...cookie, name })}
+ />
+
+
+ onChange({ ...cookie, value })}
+ />
+
+
+ onChange(cookieWithDomain(cookie, domain))}
+ />
+
+
+ onChange({ ...cookie, path })}
+ />
+
+
+
+ {
+ if (checked) {
+ onChange({ ...cookie, expires: "SessionEnd" });
+ return;
+ }
+
+ const expiresInput =
+ cookieExpiresFromInput(expiresInputValue) == null
+ ? defaultCookieExpiresInputValue()
+ : expiresInputValue;
+
+ onExpiresInputChange(expiresInput);
+ onChange({
+ ...cookie,
+ expires: cookieExpiresFromInput(expiresInput)!,
+ });
+ }}
+ />
+ {
+ onExpiresInputChange(value);
+
+ const expires = cookieExpiresFromInput(value);
+ if (expires != null) {
+ onChange({ ...cookie, expires });
+ }
+ }}
+ />
+
+
+ {cookieSize(cookie)}
+
+ onChange({ ...cookie, httpOnly })}
+ />
+
+
+ onChange({ ...cookie, secure })}
+ />
+
+
+
+ onChange({
+ ...cookie,
+ sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
+ })
+ }
+ />
+
+
+
+ );
+}
+
+function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps) {
+ return ;
+}
+
+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 (
+ 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 (
+
+
+ );
+
+ case "personal_use":
+ return (
+
+
+ Your commercial-use trial has ended.
+
+
+ You may continue using Yaak for personal use only.
+ A license is required for commercial use.
+
+
+
+
+ Learn More
+
+
+
+
+ );
+
+ case "inactive":
+ return (
+
+ Your license is invalid. Please Sign In{" "}
+ for more details
+
+ );
+
+ case "expired":
+ return (
+
+ Your license expired{" "}
+ {formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")} . Please{" "}
+ Resubscribe to continue receiving
+ updates.
+ {check.data.data.changesUrl && (
+ <>
+
+ What's new in latest builds
+ >
+ )}
+
+ );
+
+ case "past_due":
+ return (
+
+ Your payment method needs attention.
+
+ To re-activate your license, please{" "}
+ update your billing info.
+
+ );
+
+ case "error":
+ return (
+
+ License check failed: {check.data.data.message} (Code: {check.data.data.code})
+
+ );
+ }
+ };
+
+ return (
+
+ {renderBanner()}
+
+ {check.error &&
{check.error} }
+ {activate.error &&
{activate.error} }
+
+ {check.data?.status === "active" ? (
+
+ deactivate.mutate()}>
+ Deactivate License
+
+ platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
+ rightSlot={ }
+ >
+ Direct Support
+
+
+ ) : (
+
+
+ Activate License
+
+ }
+ onClick={() =>
+ platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
+ }
+ >
+ Purchase License
+
+
+ )}
+
+ {activateFormVisible && (
+
{
+ e.preventDefault();
+ await activate.mutateAsync({ licenseKey: key });
+ toggleActivateFormVisible();
+ }}
+ >
+
+
+ Submit
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/Settings/SettingsPlugins.tsx b/apps/yaak-client/components/Settings/SettingsPlugins.tsx
new file mode 100644
index 0000000..e83cdc3
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsPlugins.tsx
@@ -0,0 +1,426 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import type { Plugin } from "@yaakapp-internal/models";
+import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
+import type { PluginVersion } from "@yaakapp-internal/plugins";
+import {
+ checkPluginUpdates,
+ installPlugin,
+ searchPlugins,
+ uninstallPlugin,
+} from "@yaakapp-internal/plugins";
+import {
+ HStack,
+ Icon,
+ InlineCode,
+ LoadingIcon,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ useDebouncedValue,
+} from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { useInstallPlugin } from "../../hooks/useInstallPlugin";
+import { usePluginInfo } from "../../hooks/usePluginInfo";
+import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
+import { showConfirmDelete } from "../../lib/confirm";
+import { minPromiseMillis } from "../../lib/minPromiseMillis";
+import { Button } from "../core/Button";
+import { Checkbox } from "../core/Checkbox";
+import { CountBadge } from "../core/CountBadge";
+import { IconButton } from "../core/IconButton";
+import { Link } from "../core/Link";
+import { PlainInput } from "../core/PlainInput";
+import { TabContent, Tabs } from "../core/Tabs/Tabs";
+import { EmptyStateText } from "../EmptyStateText";
+import { SelectFile } from "../SelectFile";
+import { platform } from "@yaakapp-internal/platform";
+
+interface SettingsPluginsProps {
+ defaultSubtab?: string;
+}
+
+export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
+ const [directory, setDirectory] = useState(null);
+ const plugins = useAtomValue(pluginsAtom);
+ const bundledPlugins = plugins.filter((p) => p.source === "bundled");
+ const installedPlugins = plugins.filter((p) => p.source !== "bundled");
+ const createPlugin = useInstallPlugin();
+ const refreshPlugins = useRefreshPlugins();
+ return (
+
+
,
+ },
+ {
+ label: "Bundled",
+ value: "bundled",
+ rightSlot:
,
+ },
+ ]}
+ >
+
+
+
+
+
+
+
+ setDirectory(filePath)}
+ filePath={directory}
+ />
+
+ {directory && (
+ {
+ if (directory == null) return;
+ createPlugin.mutate(directory);
+ setDirectory(null);
+ }}
+ >
+ Add Plugin
+
+ )}
+ refreshPlugins.mutate()}
+ />
+
+ platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
+ const info = usePluginInfo(plugin.id).data;
+ if (info == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
+ const info = usePluginInfo(plugin.id).data;
+ if (info == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
+ const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
+ const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
+
+ return (
+
+ );
+}
+
+function PluginTableRow({
+ plugin,
+ name,
+ version,
+ displayName,
+ url,
+ showCheckbox = true,
+ showUninstall = true,
+}: {
+ plugin: Plugin | null;
+ name: string;
+ version: string;
+ displayName: string;
+ url: string | null;
+ showCheckbox?: boolean;
+ showUninstall?: boolean;
+}) {
+ const updates = usePluginUpdates();
+ const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
+ const installPluginMutation = useMutation({
+ mutationKey: ["install_plugin", name],
+ mutationFn: (name: string) => installPlugin(name, null),
+ });
+ const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
+ const refreshPlugins = useRefreshPlugins();
+
+ return (
+
+ {showCheckbox && (
+
+ {
+ if (plugin) {
+ await patchModel(plugin, { enabled });
+ refreshPlugins.mutate();
+ }
+ }}
+ />
+
+ )}
+
+ {url ? (
+
+ {displayName}
+
+ ) : (
+ displayName
+ )}
+
+
+ {name}
+
+
+
+ {version}
+ {latestVersion != null && (
+
+
+ {latestVersion}
+
+ )}
+
+
+
+
+ {plugin != null && latestVersion != null ? (
+ installPluginMutation.mutate(name)}
+ >
+ Update
+
+ ) : plugin == null ? (
+ installPluginMutation.mutate(name)}
+ >
+ Install
+
+ ) : null}
+ {showUninstall && uninstall != null && (
+ uninstall.mutate()}
+ >
+ Uninstall
+
+ )}
+
+
+
+ );
+}
+
+function PluginSearch() {
+ const [query, setQuery] = useState("");
+ const debouncedQuery = useDebouncedValue(query);
+ const results = useQuery({
+ queryKey: ["plugins", debouncedQuery],
+ queryFn: () => searchPlugins(query),
+ });
+
+ return (
+
+
+
+
+
+ {results.data == null ? (
+
+
+
+ ) : (results.data.plugins ?? []).length === 0 ? (
+
No plugins found
+ ) : (
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {results.data.plugins.map((p) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
+ return plugins.length === 0 ? (
+
+
+ Plugins extend the functionality of Yaak.
+
+ Add your first plugin to get started.
+
+
+ ) : (
+
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {plugins.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
+ return plugins.length === 0 ? (
+
+ No bundled plugins found.
+
+ ) : (
+
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {plugins.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+function usePromptUninstall(pluginId: string | null, name: string) {
+ const mut = useMutation({
+ mutationKey: ["uninstall_plugin", pluginId],
+ mutationFn: async () => {
+ if (pluginId == null) return;
+
+ const confirmed = await showConfirmDelete({
+ id: `uninstall-plugin-${pluginId}`,
+ title: "Uninstall Plugin",
+ confirmText: "Uninstall",
+ description: (
+ <>
+ Permanently uninstall {name} ?
+ >
+ ),
+ });
+ if (confirmed) {
+ await minPromiseMillis(uninstallPlugin(pluginId), 700);
+ }
+ },
+ });
+
+ return pluginId == null ? null : mut;
+}
+
+function usePluginUpdates() {
+ return useQuery({
+ queryKey: ["plugin_updates", usePluginsKey()],
+ queryFn: () => checkPluginUpdates(),
+ });
+}
diff --git a/apps/yaak-client/components/Settings/SettingsProxy.tsx b/apps/yaak-client/components/Settings/SettingsProxy.tsx
new file mode 100644
index 0000000..98127b7
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsProxy.tsx
@@ -0,0 +1,180 @@
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import type { ProxySetting } from "@yaakapp-internal/models";
+import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { CommercialUseBanner } from "../CommercialUseBanner";
+import {
+ SettingRowBoolean,
+ SettingRowSelect,
+ SettingRowText,
+ SettingsList,
+ SettingsSection,
+} from "../core/SettingRow";
+
+export function SettingsProxy() {
+ const settings = useAtomValue(settingsAtom);
+ const proxy = enabledProxyOrDefault(settings.proxy);
+
+ const patchProxy = async (patch: Partial) => {
+ await patchModel(settings, {
+ proxy: {
+ ...proxy,
+ ...patch,
+ auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
+ },
+ });
+ };
+
+ return (
+
+
+
Proxy
+
+ Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
+ traffic, or routing through specific infrastructure.
+
+
+
+
+
+ {
+ 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!"
+ />
+
+
+ {settings.proxy?.type === "enabled" && (
+ <>
+
+ patchProxy({ disabled: !enabled })}
+ />
+
+ Proxy for http:// traffic
+ >
+ }
+ description="Proxy host used for unencrypted HTTP traffic."
+ value={settings.proxy.http}
+ placeholder="localhost:9090"
+ onChange={(http) => patchProxy({ http })}
+ />
+
+ Proxy for https:// traffic
+ >
+ }
+ description="Proxy host used for HTTPS traffic."
+ value={settings.proxy.https}
+ placeholder="localhost:9090"
+ onChange={(https) => patchProxy({ https })}
+ />
+ patchProxy({ bypass })}
+ />
+
+
+
+
+ patchProxy({ auth: enabled ? { user: "", password: "" } : null })
+ }
+ />
+
+ {settings.proxy.auth != null && (
+ <>
+
+ patchProxy({
+ auth: {
+ user,
+ password:
+ settings.proxy?.type === "enabled"
+ ? (settings.proxy.auth?.password ?? "")
+ : "",
+ },
+ })
+ }
+ />
+
+ patchProxy({
+ auth: {
+ user:
+ settings.proxy?.type === "enabled"
+ ? (settings.proxy.auth?.user ?? "")
+ : "",
+ password,
+ },
+ })
+ }
+ />
+ >
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
+type EnabledProxySetting = Extract;
+
+function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
+ if (proxy?.type === "enabled") return proxy;
+
+ return {
+ disabled: false,
+ type: "enabled",
+ http: "",
+ https: "",
+ auth: { user: "", password: "" },
+ bypass: "",
+ };
+}
diff --git a/apps/yaak-client/components/Settings/SettingsTheme.tsx b/apps/yaak-client/components/Settings/SettingsTheme.tsx
new file mode 100644
index 0000000..2681090
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsTheme.tsx
@@ -0,0 +1,173 @@
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { lazy, Suspense } from "react";
+import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
+import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
+import { useResolvedTheme } from "../../hooks/useResolvedTheme";
+import type { ButtonProps } from "../core/Button";
+import { IconButton } from "../core/IconButton";
+import { Link } from "../core/Link";
+import type { SelectProps } from "../core/Select";
+import {
+ ModelSettingRowSelect,
+ SettingRowSelect,
+ SettingsList,
+ SettingsSection,
+} from "../core/SettingRow";
+
+const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
+
+const buttonColors: ButtonProps["color"][] = [
+ "primary",
+ "info",
+ "success",
+ "notice",
+ "warning",
+ "danger",
+ "secondary",
+ "default",
+];
+
+const icons: IconProps["icon"][] = [
+ "info",
+ "box",
+ "update",
+ "alert_triangle",
+ "arrow_big_right_dash",
+ "download",
+ "copy",
+ "magic_wand",
+ "settings",
+ "trash",
+ "sparkles",
+ "pencil",
+ "paste",
+ "search",
+ "send_horizontal",
+];
+
+export function SettingsTheme() {
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const settings = useAtomValue(settingsAtom);
+ const appearance = useResolvedAppearance();
+ const activeTheme = useResolvedTheme();
+
+ if (settings == null || workspace == null || activeTheme.data == null) {
+ return null;
+ }
+
+ const lightThemes: SelectProps["options"] = activeTheme.data.themes
+ .filter((theme) => !theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ const darkThemes: SelectProps["options"] = activeTheme.data.themes
+ .filter((theme) => theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ return (
+
+
+
Theme
+
+ Make Yaak your own by selecting a theme, or{" "}
+
+ Create Your Own
+
+
+
+
+
+
+ {(settings.appearance === "system" || settings.appearance === "light") && (
+ patchModel(settings, { themeLight })}
+ />
+ )}
+ {(settings.appearance === "system" || settings.appearance === "dark") && (
+ patchModel(settings, { themeDark })}
+ />
+ )}
+
+
+
+
+
+
+ {activeTheme.data.active.label}
+ (preview)
+
+
+ {buttonColors.map((c, i) => (
+
+ ))}
+ {buttonColors.map((c, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/SettingsDropdown.tsx b/apps/yaak-client/components/SettingsDropdown.tsx
new file mode 100644
index 0000000..2c9336b
--- /dev/null
+++ b/apps/yaak-client/components/SettingsDropdown.tsx
@@ -0,0 +1,113 @@
+import { useLicense } from "@yaakapp-internal/license";
+import { useRef } from "react";
+import { openSettings } from "../commands/openSettings";
+import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
+import { useExportData } from "../hooks/useExportData";
+import { appInfo } from "../lib/appInfo";
+import { showDialog } from "../lib/dialog";
+import { importData } from "../lib/importData";
+import { pricingUrl } from "../lib/pricingUrl";
+import type { DropdownRef } from "./core/Dropdown";
+import { Dropdown } from "./core/Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+import { IconButton } from "./core/IconButton";
+import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
+import { platform } from "@yaakapp-internal/platform";
+
+export function SettingsDropdown() {
+ const exportData = useExportData();
+ const dropdownRef = useRef(null);
+ const checkForUpdates = useCheckForUpdates();
+ const { check } = useLicense();
+
+ return (
+ ,
+ onSelect: () => openSettings.mutate(null),
+ },
+ {
+ label: "Keyboard shortcuts",
+ hotKeyAction: "hotkeys.showHelp",
+ leftSlot: ,
+ onSelect: () => {
+ showDialog({
+ id: "hotkey",
+ title: "Keyboard Shortcuts",
+ size: "dynamic",
+ render: () => ,
+ });
+ },
+ },
+ {
+ label: "Plugins",
+ leftSlot: ,
+ onSelect: () => openSettings.mutate("plugins"),
+ },
+ { type: "separator", label: "Share Workspace(s)" },
+ {
+ label: "Import Data",
+ leftSlot: ,
+ onSelect: () => importData.mutate(),
+ },
+ {
+ label: "Export Data",
+ leftSlot: ,
+ onSelect: () => exportData.mutate(),
+ },
+ {
+ label: "Create Run Button",
+ leftSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/button/new"),
+ },
+ { type: "separator", label: `Yaak v${appInfo.version}` },
+ {
+ label: "Check for Updates",
+ leftSlot: ,
+ hidden: !appInfo.featureUpdater,
+ onSelect: () => checkForUpdates.mutate(),
+ },
+ {
+ label: "Purchase License",
+ color: "success",
+ hidden: check.data == null || check.data.status === "active",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () =>
+ platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
+ },
+ {
+ label: "Install CLI",
+ hidden: appInfo.cliVersion != null,
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
+ },
+ {
+ label: "Feedback",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/feedback"),
+ },
+ {
+ label: "Changelog",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
+ },
+ ]}
+ >
+
+
+ );
+}
diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx
new file mode 100644
index 0000000..3e600a2
--- /dev/null
+++ b/apps/yaak-client/components/Sidebar.tsx
@@ -0,0 +1,1182 @@
+import type { Extension } from "@codemirror/state";
+import { Compartment } from "@codemirror/state";
+import { debounce } from "@yaakapp-internal/lib";
+import { gitMutations } from "@yaakapp-internal/git";
+import type { GitStatus } from "@yaakapp-internal/git";
+import type {
+ AnyModel,
+ Folder,
+ GrpcRequest,
+ HttpRequest,
+ ModelPayload,
+ WebsocketRequest,
+ Workspace,
+} from "@yaakapp-internal/models";
+import {
+ duplicateModel,
+ foldersAtom,
+ getAnyModel,
+ getModel,
+ grpcConnectionsAtom,
+ httpResponsesAtom,
+ patchModel,
+ websocketConnectionsAtom,
+ workspacesAtom,
+} from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { atom, useAtomValue } from "jotai";
+import { atomFamily } from "jotai-family";
+import { selectAtom } from "jotai/utils";
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { moveToWorkspace } from "../commands/moveToWorkspace";
+import { openFolderSettings } from "../commands/openFolderSettings";
+import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
+import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
+import {
+ activeWorkspaceAtom,
+ activeWorkspaceIdAtom,
+ activeWorkspaceMetaAtom,
+} from "../hooks/useActiveWorkspace";
+import { allRequestsAtom } from "../hooks/useAllRequests";
+import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
+import { getFolderActions } from "../hooks/useFolderActions";
+import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
+import { useHotKey } from "../hooks/useHotKey";
+import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
+import { platform } from "@yaakapp-internal/platform";
+import { usePlatformEvent } from "../hooks/usePlatformEvent";
+import { getModelAncestors } from "../hooks/useModelAncestors";
+import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
+import { deepEqualAtom } from "../lib/atoms";
+import { showConfirm } from "../lib/confirm";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+import { showDialog } from "../lib/dialog";
+import {
+ gitWorktreeStatusByModelIdAtom,
+ gitWorktreeStatusFamily,
+} from "../lib/gitWorktreeStatus";
+import { jotaiStore } from "../lib/jotai";
+import { resolvedModelName } from "../lib/resolvedModelName";
+import { isSidebarFocused } from "../lib/scopes";
+import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
+import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
+import { ContextMenu, Dropdown } from "./core/Dropdown";
+import type { FieldDef } from "./core/Editor/filter/extension";
+import { filter } from "./core/Editor/filter/extension";
+import type { Ast } from "./core/Editor/filter/query";
+import { evaluate, parseQuery } from "./core/Editor/filter/query";
+import { formatFieldFilter } from "./core/Editor/filter/format";
+import { HttpMethodTag } from "./core/HttpMethodTag";
+import { HttpStatusTag } from "./core/HttpStatusTag";
+import {
+ Icon,
+ LoadingIcon,
+ Tree,
+ isSelectedFamily,
+ selectedIdsFamily,
+ InlineCode,
+} from "@yaakapp-internal/ui";
+import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
+import { IconButton } from "./core/IconButton";
+import type { InputHandle } from "./core/Input";
+import { Input } from "./core/Input";
+import { EmptyStateText } from "./EmptyStateText";
+import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
+import { GitDropdown } from "./git/GitDropdown";
+import { gitCallbacks } from "./git/callbacks";
+import { FileHistoryDialog } from "./git/FileHistoryDialog";
+import { sync } from "../init/sync";
+
+const collapsedFamily = atomFamily((treeId: string) => {
+ const key = ["sidebar_collapsed", treeId ?? "n/a"];
+ return atomWithKVStorage>(key, {});
+});
+
+type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
+function isSidebarLeafModel(m: AnyModel): boolean {
+ const modelMap: Record, null> = {
+ http_request: null,
+ grpc_request: null,
+ websocket_request: null,
+ folder: null,
+ };
+ return m.model in modelMap;
+}
+
+const OPACITY_SUBTLE = "opacity-80";
+
+function Sidebar({ className }: { className?: string }) {
+ const [hidden, setHidden] = useSidebarHidden();
+ const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
+ const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
+ const filterText = useAtomValue(sidebarFilterAtom);
+ const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
+
+ const wrapperRef = useRef(null);
+ const treeRef = useRef(null);
+ const filterRef = useRef(null);
+ const setFilterRef = useCallback((h: InputHandle | null) => {
+ filterRef.current = h;
+ }, []);
+ const allHidden = useMemo(() => {
+ if (tree?.children?.length === 0) return false;
+ if (filterText) return tree?.children?.every((c) => c.hidden);
+ return true;
+ }, [filterText, tree?.children]);
+
+ const focusActiveItem = useCallback(() => {
+ const didFocus = treeRef.current?.focus();
+ // If we weren't able to focus any items, focus the filter bar
+ if (!didFocus) filterRef.current?.focus();
+ }, []);
+
+ // Focus new sidebar models created by the user in this window. Writes from other
+ // sources (import, sync, CLI) can carry thousands of models and shouldn't move
+ // the selection.
+ usePlatformEvent("model_writes", (payloads) => {
+ for (const payload of payloads) {
+ if (payload.updateSource.type !== "window") continue;
+ if (payload.updateSource.label !== platform.window.label) continue;
+ if (!isSidebarLeafModel(payload.model)) continue;
+ if (!(payload.change.type === "upsert" && payload.change.created)) continue;
+ treeRef.current?.selectItem(payload.model.id, true);
+ }
+ });
+
+ useEffect(() => {
+ return jotaiStore.sub(activeIdAtom, () => {
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId) {
+ treeRef.current?.selectItem(activeId, true);
+ }
+ });
+ }, []);
+
+ useHotKey(
+ "sidebar.filter",
+ () => {
+ filterRef.current?.focus();
+ },
+ {
+ enable: isSidebarFocused,
+ },
+ );
+
+ useHotKey("sidebar.focus", async function focusHotkey() {
+ // Hide the sidebar if it's already focused
+ if (!hidden && isSidebarFocused()) {
+ await setHidden(true);
+ return;
+ }
+
+ // Show the sidebar if it's hidden
+ if (hidden) {
+ await setHidden(false);
+ }
+
+ // Select the 0th index on focus if none selected
+ setTimeout(focusActiveItem, 100);
+ });
+
+ const handleDragEnd = useCallback(async function handleDragEnd({
+ items,
+ parent,
+ children,
+ insertAt,
+ }: {
+ items: SidebarModel[];
+ parent: SidebarModel;
+ children: SidebarModel[];
+ insertAt: number;
+ }) {
+ const prev = children[insertAt - 1] as Exclude;
+ const next = children[insertAt] as Exclude;
+ const folderId = parent.model === "folder" ? parent.id : null;
+
+ const beforePriority = prev?.sortPriority ?? 0;
+ const afterPriority = next?.sortPriority ?? 0;
+ const shouldUpdateAll = afterPriority - beforePriority < 1;
+
+ try {
+ if (shouldUpdateAll) {
+ // Add items to children at insertAt
+ children.splice(insertAt, 0, ...items);
+ await Promise.all(
+ children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
+ );
+ } else {
+ const range = afterPriority - beforePriority;
+ const increment = range / (items.length + 2);
+ await Promise.all(
+ items.map((m, i) =>
+ // Spread item sortPriority out over before/after range
+ patchModel(m, {
+ sortPriority: beforePriority + (i + 1) * increment,
+ folderId,
+ }),
+ ),
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }, []);
+
+ const handleTreeRefInit = useCallback(
+ (n: TreeHandle) => {
+ treeRef.current = n;
+ if (n == null) return;
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId == null) return;
+ const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
+ if (selectedIds.length > 0) return;
+ n.selectItem(activeId);
+ },
+ [treeId],
+ );
+
+ const clearFilterText = useCallback(() => {
+ setSidebarFilterText("");
+ requestAnimationFrame(() => {
+ filterRef.current?.focus();
+ });
+ }, []);
+
+ const handleFilterKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ e.stopPropagation(); // Don't trigger tree navigation hotkeys
+ if (e.key === "Escape") {
+ e.preventDefault();
+ clearFilterText();
+ }
+ },
+ [clearFilterText],
+ );
+
+ const handleFilterChange = useMemo(
+ () =>
+ debounce((text: string) => {
+ jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
+ }, 0),
+ [],
+ );
+
+ const applyFilterExample = useCallback((text: string) => {
+ setSidebarFilterText(text);
+ requestAnimationFrame(() => {
+ filterRef.current?.focus();
+ });
+ }, []);
+
+ const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
+
+ const getSelectedTreeModels = useCallback(
+ () => treeRef.current?.getSelectedItems() as SidebarModel[] | undefined,
+ [],
+ );
+
+ const handleRenameSelected = useCallback((items: SidebarModel[]) => {
+ if (items.length === 1 && items[0] != null) {
+ treeRef.current?.renameItem(items[0].id);
+ }
+ }, []);
+
+ const handleDeleteSelected = useCallback(async (items: SidebarModel[]) => {
+ await deleteModelWithConfirm(items);
+ }, []);
+
+ const handleDuplicateSelected = useCallback(async (items: SidebarModel[]) => {
+ if (items.length === 1 && items[0]) {
+ const newId = await duplicateModel(items[0]);
+ navigateToRequestOrFolderOrWorkspace(newId, items[0].model);
+ } else {
+ await Promise.all(items.map(duplicateModel));
+ }
+ }, []);
+
+ const handleMoveSelected = useCallback((items: SidebarModel[]) => {
+ const requests = items.filter(
+ (i): i is HttpRequest | GrpcRequest | WebsocketRequest =>
+ i.model === "http_request" || i.model === "grpc_request" || i.model === "websocket_request",
+ );
+ if (requests.length > 0) {
+ moveToWorkspace.mutate(requests);
+ }
+ }, []);
+
+ const handleSendSelected = useCallback(async (items: SidebarModel[]) => {
+ await Promise.all(
+ items
+ .filter((i) => i.model === "http_request")
+ .map((i) => sendAnyHttpRequest.mutateAsync(i.id)),
+ );
+ }, []);
+
+ useHotKey(
+ "sidebar.context_menu",
+ useCallback(() => {
+ treeRef.current?.showContextMenu();
+ }, []),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.expand_all",
+ useCallback(() => {
+ jotaiStore.set(collapsedFamily(treeId), {});
+ }, [treeId]),
+ { enable: isSidebarFocused },
+ );
+
+ useHotKey(
+ "sidebar.collapse_all",
+ useCallback(() => {
+ if (tree == null) return;
+ const next = (node: TreeNode, collapsed: Record) => {
+ let newCollapsed = { ...collapsed };
+ for (const n of node.children ?? []) {
+ if (n.item.model !== "folder") continue;
+ newCollapsed[n.item.id] = true;
+ newCollapsed = next(n, newCollapsed);
+ }
+ return newCollapsed;
+ };
+ const collapsed = next(tree, {});
+ jotaiStore.set(collapsedFamily(treeId), collapsed);
+ }, [tree, treeId]),
+ { enable: isSidebarFocused },
+ );
+
+ useHotKey(
+ "sidebar.selected.delete",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) void handleDeleteSelected(items);
+ }, [getSelectedTreeModels, handleDeleteSelected]),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.selected.rename",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) handleRenameSelected(items);
+ }, [getSelectedTreeModels, handleRenameSelected]),
+ { enable: treeHasFocus, allowDefault: true },
+ );
+
+ useHotKey(
+ "sidebar.selected.duplicate",
+ useCallback(async () => {
+ const items = getSelectedTreeModels();
+ if (items) await handleDuplicateSelected(items);
+ }, [getSelectedTreeModels, handleDuplicateSelected]),
+ { priority: 10, enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.selected.move",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) handleMoveSelected(items);
+ }, [getSelectedTreeModels, handleMoveSelected]),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "request.send",
+ useCallback(async () => {
+ const items = getSelectedTreeModels();
+ if (items) await handleSendSelected(items);
+ }, [getSelectedTreeModels, handleSendSelected]),
+ { enable: treeHasFocus },
+ );
+
+ const getContextMenu = useCallback<(items: SidebarModel[]) => Promise>(
+ async (items) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const child = items[0];
+
+ // No children means we're in the root
+ if (child == null) {
+ return getCreateDropdownItems({
+ workspaceId,
+ activeRequest: null,
+ folderId: null,
+ });
+ }
+
+ const workspaces = jotaiStore.get(workspacesAtom);
+ const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
+ const gitItems = getGitContextMenuItems({ items, syncDir });
+ const onlyHttpRequests = items.every((i) => i.model === "http_request");
+ const requestItems = items.filter(
+ (i) =>
+ i.model === "http_request" ||
+ i.model === "grpc_request" ||
+ i.model === "websocket_request",
+ );
+
+ const initialItems: ContextMenuProps["items"] = [
+ {
+ label: "Folder Settings",
+ hidden: !(items.length === 1 && child.model === "folder"),
+ leftSlot: ,
+ onSelect: () => openFolderSettings(child.id),
+ },
+ {
+ label: "Send",
+ hotKeyAction: "request.send",
+ hotKeyLabelOnly: true,
+ hidden: !onlyHttpRequests,
+ leftSlot: ,
+ onSelect: () => handleSendSelected(items),
+ },
+ ...(items.length === 1 && child.model === "http_request"
+ ? await getHttpRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("http_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "grpc_request"
+ ? await getGrpcRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("grpc_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "websocket_request"
+ ? await getWebsocketRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("websocket_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "folder" ? await getFolderActions() : []).map(
+ (a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const model = getModel("folder", child.id);
+ if (model != null) await a.call(model);
+ },
+ }),
+ ),
+ ];
+ const modelCreationItems: DropdownItem[] =
+ items.length === 1 && child.model === "folder"
+ ? [
+ { type: "separator" },
+ ...getCreateDropdownItems({
+ workspaceId,
+ activeRequest: null,
+ folderId: child.id,
+ }),
+ ]
+ : [];
+ const menuItems: ContextMenuProps["items"] = [
+ ...initialItems,
+ {
+ type: "separator",
+ hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
+ },
+ ...gitItems,
+ { type: "separator", hidden: gitItems.length === 0 },
+ {
+ label: "Rename",
+ leftSlot: ,
+ hidden: items.length > 1,
+ hotKeyAction: "sidebar.selected.rename",
+ hotKeyLabelOnly: true,
+ onSelect: () => handleRenameSelected(items),
+ },
+ {
+ label: "Duplicate",
+ hotKeyAction: "model.duplicate",
+ hotKeyLabelOnly: true, // Would trigger for every request (bad)
+ leftSlot: ,
+ onSelect: () => handleDuplicateSelected(items),
+ },
+ {
+ label: items.length <= 1 ? "Move" : `Move ${requestItems.length} Requests`,
+ hotKeyAction: "sidebar.selected.move",
+ hotKeyLabelOnly: true,
+ leftSlot: ,
+ hidden:
+ workspaces.length <= 1 ||
+ requestItems.length === 0 ||
+ requestItems.length !== items.length,
+ onSelect: () => handleMoveSelected(items),
+ },
+ {
+ color: "danger",
+ label: "Delete",
+ hotKeyAction: "sidebar.selected.delete",
+ hotKeyLabelOnly: true,
+ leftSlot: ,
+ onSelect: () => handleDeleteSelected(items),
+ },
+ ...modelCreationItems,
+ ];
+ return menuItems;
+ },
+ [],
+ );
+
+ const renderContextMenuFn = useCallback<
+ NonNullable["renderContextMenu"]>
+ >(
+ ({ items, position, onClose }) => (
+
+ ),
+ [],
+ );
+
+ // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
+ const filterLanguageCompartmentRef = useRef(new Compartment());
+ const filterCompartmentMountExtRef = useRef(null);
+ if (filterCompartmentMountExtRef.current == null) {
+ filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
+ filter({ fields: allFields ?? [] }),
+ );
+ }
+
+ useEffect(() => {
+ const view = filterRef.current;
+ if (!view) return;
+ const ext = filter({ fields: allFields ?? [] });
+ view.dispatch({
+ effects: filterLanguageCompartmentRef.current.reconfigure(ext),
+ });
+ }, [allFields]);
+
+ if (tree == null || hidden) {
+ return null;
+ }
+
+ return (
+
+
+ {(tree.children?.length ?? 0) > 0 && (
+ <>
+
+ )
+ }
+ />
+ ,
+ onSelect: () => {
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId == null) return;
+
+ const folders = jotaiStore.get(foldersAtom);
+ const workspaces = jotaiStore.get(workspacesAtom);
+ const currentModel = getAnyModel(activeId);
+ const ancestors = getModelAncestors(folders, workspaces, currentModel);
+ jotaiStore.set(collapsedFamily(treeId), (prev) => {
+ const n = { ...prev };
+ for (const ancestor of ancestors) {
+ if (ancestor.model === "folder") {
+ delete n[ancestor.id];
+ }
+ }
+ return n;
+ });
+ treeRef.current?.selectItem(activeId, false);
+ treeRef.current?.focus();
+ },
+ },
+ {
+ label: "Expand All Folders",
+ leftSlot: ,
+ onSelect: () => jotaiStore.set(collapsedFamily(treeId), {}),
+ hotKeyAction: "sidebar.expand_all",
+ hotKeyLabelOnly: true,
+ },
+ {
+ label: "Collapse All Folders",
+ leftSlot: ,
+ onSelect: () => {
+ if (tree == null) return;
+ const next = (
+ node: TreeNode,
+ collapsed: Record,
+ ) => {
+ let newCollapsed = { ...collapsed };
+ for (const n of node.children ?? []) {
+ if (n.item.model !== "folder") continue;
+ newCollapsed[n.item.id] = true;
+ newCollapsed = next(n, newCollapsed);
+ }
+ return newCollapsed;
+ };
+ jotaiStore.set(collapsedFamily(treeId), next(tree, {}));
+ },
+ hotKeyAction: "sidebar.collapse_all",
+ hotKeyLabelOnly: true,
+ },
+ ]}
+ >
+
+
+ >
+ )}
+
+ {allHidden ? (
+
+ {(emptyFilterSuggestions?.length ?? 0) > 0 ? (
+
+
+ No results, but found matches for{" "}
+ {emptyFilterSuggestions?.map((suggestion, i) => (
+
+ {i > 0 && " or "}
+ applyFilterExample(suggestion.filterText)}
+ >
+
+ {suggestion.filterText}
+
+
+
+ ))}
+
+
+ ) : (
+
+
+ No results for{" "}
+
+ {filterText.text}
+
+
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// Memoized so route navigations (which re-render the workspace layout) don't
+// re-render the sidebar subtree. In large workspaces a sidebar re-render is
+// very expensive: it re-renders DndContext, whose context churn re-renders
+// every visible TreeItem regardless of their memo comparators.
+export default memo(Sidebar);
+
+function getGitContextMenuItems({
+ items,
+ syncDir,
+}: {
+ items: SidebarModel[];
+ syncDir: string | null | undefined;
+}): DropdownItem[] {
+ if (syncDir == null) return [];
+
+ const gitStatusEntries = items.flatMap((item) => {
+ const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
+ return status == null || status.status === "current" ? [] : [status];
+ });
+ const historyItem = items.length === 1 ? items[0] : null;
+ const historyPath =
+ historyItem == null
+ ? null
+ : (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
+ syncPathForModel(historyItem));
+
+ return [
+ {
+ label: "View History",
+ leftSlot: ,
+ hidden: historyPath == null,
+ onSelect: () => {
+ if (historyPath == null) return;
+ showDialog({
+ id: "git-history",
+ size: "lg",
+ title: "File History",
+ noPadding: true,
+ noScroll: true,
+ render: () => ,
+ });
+ },
+ },
+ {
+ label: "Restore Changes",
+ leftSlot: ,
+ hidden: gitStatusEntries.length === 0,
+ async onSelect() {
+ const confirmed = await showConfirm({
+ id: "git-restore-sidebar-items",
+ title: "Restore Changes",
+ description:
+ gitStatusEntries.length === 1
+ ? "This will discard uncommitted changes for the selected item."
+ : `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
+ confirmText: "Restore",
+ color: "danger",
+ });
+ if (!confirmed) return;
+
+ await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
+ relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
+ });
+ await sync({ force: true });
+ },
+ },
+ ];
+}
+
+function syncPathForModel(item: SidebarModel) {
+ return `yaak.${item.id}.yaml`;
+}
+
+const activeIdAtom = atom((get) => {
+ return get(activeRequestIdAtom) || get(activeFolderIdAtom);
+});
+
+function getEditOptions(
+ item: SidebarModel,
+): ReturnType["getEditOptions"]>> {
+ return {
+ onChange: handleSubmitEdit,
+ defaultValue: resolvedModelName(item),
+ placeholder: item.name,
+ };
+}
+
+async function handleSubmitEdit(item: SidebarModel, text: string) {
+ await patchModel(item, { name: text });
+}
+
+function handleActivate(item: SidebarModel) {
+ // TODO: Add folder layout support
+ if (item.model !== "folder" && item.model !== "workspace") {
+ navigateToRequestOrFolderOrWorkspace(item.id, item.model);
+ }
+}
+
+const allPotentialChildrenAtom = atom((get) => {
+ const requests = get(allRequestsAtom);
+ const folders = get(foldersAtom);
+ return [...requests, ...folders];
+});
+
+const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
+
+const sidebarFilterAtom = atom<{ text: string; key: string }>({
+ text: "",
+ key: "",
+});
+
+type SidebarFilterSuggestion = {
+ field: string;
+ filterText: string;
+};
+
+function setSidebarFilterText(text: string) {
+ jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
+}
+
+function getSidebarSuggestionValue(ast: Ast | null) {
+ if (ast == null) return null;
+
+ if (ast.type === "Term" || ast.type === "Phrase") {
+ const value = ast.value.trim();
+ return value.length > 0 ? value : null;
+ }
+
+ if (ast.type === "Field") {
+ const value = ast.value.trim();
+ return value.length > 0 ? value : null;
+ }
+
+ return null;
+}
+
+function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
+ return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
+}
+
+const sidebarSuggestionFieldOrder = [
+ "url",
+ "folder",
+ "method",
+ "type",
+ "grpc_service",
+ "grpc_method",
+ "name",
+];
+
+const sidebarTreeAtom = atom<
+ [TreeNode, FieldDef[], SidebarFilterSuggestion[]] | null
+>((get) => {
+ const allModels = get(memoAllPotentialChildrenAtom);
+ const activeWorkspace = get(activeWorkspaceAtom);
+ const filter = get(sidebarFilterAtom);
+
+ const childrenMap: Record[]> = {};
+ for (const item of allModels) {
+ if ("folderId" in item && item.folderId == null) {
+ childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
+ childrenMap[item.workspaceId]?.push(item);
+ } else if ("folderId" in item && item.folderId != null) {
+ childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
+ childrenMap[item.folderId]?.push(item);
+ }
+ }
+
+ if (activeWorkspace == null) {
+ return null;
+ }
+
+ const queryAst = parseQuery(filter.text);
+ const suggestionValue = getSidebarSuggestionValue(queryAst);
+
+ // returns true if this node OR any child matches the filter
+ const allFields: Record> = {};
+ const suggestionFields = new Set();
+ const build = (node: TreeNode, depth: number): boolean => {
+ const childItems = childrenMap[node.item.id] ?? [];
+ let matchesSelf = true;
+ const fields = getItemFields(node);
+ const model = node.item.model;
+ const isLeafNode = !(model === "folder" || model === "workspace");
+
+ for (const [field, value] of Object.entries(fields)) {
+ if (!value) continue;
+ allFields[field] = allFields[field] ?? new Set();
+ allFields[field].add(value);
+ if (
+ isLeafNode &&
+ suggestionValue != null &&
+ sidebarFieldMatchesValue(value, suggestionValue)
+ ) {
+ suggestionFields.add(field);
+ }
+ }
+
+ if (queryAst != null) {
+ matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
+ }
+
+ let matchesChild = false;
+
+ // Recurse to children
+ node.children = !isLeafNode ? [] : undefined;
+
+ if (node.children != null) {
+ childItems.sort((a, b) => {
+ if (a.sortPriority === b.sortPriority) {
+ return a.updatedAt > b.updatedAt ? 1 : -1;
+ }
+ return a.sortPriority - b.sortPriority;
+ });
+
+ for (const item of childItems) {
+ const childNode = { item, parent: node, depth };
+ const childMatches = build(childNode, depth + 1);
+ if (childMatches) {
+ matchesChild = true;
+ }
+ node.children.push(childNode);
+ }
+ }
+
+ // hide node IFF nothing in its subtree matches
+ const anyMatch = matchesSelf || matchesChild;
+ node.hidden = !anyMatch;
+
+ return anyMatch;
+ };
+
+ const root: TreeNode = {
+ item: activeWorkspace,
+ parent: null,
+ children: [],
+ depth: 0,
+ };
+
+ // Build tree and mark visibility in one pass
+ build(root, 1);
+
+ const fields: FieldDef[] = [];
+ for (const [name, values] of Object.entries(allFields)) {
+ fields.push({
+ name,
+ values: Array.from(values).filter((v) => v.length < 20),
+ });
+ }
+ const suggestions = Array.from(suggestionFields)
+ .sort((a, b) => {
+ const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
+ const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
+ if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
+ return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
+ })
+ .map((field) => ({
+ field,
+ filterText: formatFieldFilter(field, suggestionValue ?? ""),
+ }));
+ return [root, fields, suggestions] as const;
+});
+
+const sidebarGitStatusByModelIdAtom = atom>((get) => {
+ const allModels = get(memoAllPotentialChildrenAtom);
+ const activeWorkspace = get(activeWorkspaceAtom);
+ const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
+ const childrenMap: Record[]> = {};
+ const statusByModelId: Record = {};
+
+ for (const item of allModels) {
+ if ("folderId" in item && item.folderId == null) {
+ childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
+ childrenMap[item.workspaceId]?.push(item);
+ } else if ("folderId" in item && item.folderId != null) {
+ childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
+ childrenMap[item.folderId]?.push(item);
+ }
+ }
+
+ const visit = (item: SidebarModel): GitStatus | null => {
+ const statuses: GitStatus[] = [];
+ const directStatus = gitStatusByModelId[item.id]?.status;
+ if (directStatus != null && directStatus !== "current") {
+ statuses.push(directStatus);
+ }
+
+ for (const child of childrenMap[item.id] ?? []) {
+ const childStatus = visit(child);
+ if (childStatus != null) statuses.push(childStatus);
+ }
+
+ const status = summarizeGitStatuses(statuses);
+ if (status != null) {
+ statusByModelId[item.id] = status;
+ }
+ return status;
+ };
+
+ if (activeWorkspace != null) {
+ visit(activeWorkspace);
+ }
+
+ return statusByModelId;
+});
+
+const sidebarGitStatusFamily = atomFamily(
+ (modelId: string) =>
+ selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
+ Object.is,
+);
+
+function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
+ if (statuses.length === 0) return null;
+ const firstStatus = statuses[0];
+ if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
+ return firstStatus;
+ }
+ return "modified";
+}
+
+function getItemKey(item: SidebarModel) {
+ const responses = jotaiStore.get(httpResponsesAtom);
+ const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
+ const url = "url" in item ? item.url : "n/a";
+ const method = "method" in item ? item.method : "n/a";
+ const service = "service" in item ? item.service : "n/a";
+ return [
+ item.id,
+ item.name,
+ url,
+ method,
+ service,
+ latestResponse?.elapsed,
+ latestResponse?.id ?? "n/a",
+ ].join("::");
+}
+
+const SidebarLeftSlot = memo(function SidebarLeftSlot({
+ treeId,
+ item,
+}: {
+ treeId: string;
+ item: SidebarModel;
+}) {
+ if (item.model === "folder") {
+ return ;
+ }
+ if (item.model === "workspace") {
+ return null;
+ }
+ const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
+ return (
+
+ );
+});
+
+const SidebarInnerItem = memo(function SidebarInnerItem({
+ item,
+}: {
+ treeId: string;
+ item: SidebarModel;
+}) {
+ const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
+ const response = useAtomValue(
+ useMemo(
+ () =>
+ selectAtom(
+ atom((get) => [
+ ...get(grpcConnectionsAtom),
+ ...get(httpResponsesAtom),
+ ...get(websocketConnectionsAtom),
+ ]),
+ (responses) => responses.find((r) => r.requestId === item.id),
+ (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
+ ),
+ [item.id],
+ ),
+ );
+
+ return (
+
+
+ {resolvedModelName(item)}
+
+ {response != null && (
+
+ {response.state !== "closed" ? (
+
+ ) : response.model === "http_response" ? (
+
+ ) : null}
+
+ )}
+
+ );
+});
+
+function getItemFields(node: TreeNode): Record {
+ const item = node.item;
+
+ if (item.model === "workspace") return {};
+
+ const fields: Record = {};
+ if (item.model === "http_request") {
+ fields.method = item.method.toUpperCase();
+ }
+
+ if (item.model === "grpc_request") {
+ fields.grpc_method = item.method ?? "";
+ fields.grpc_service = item.service ?? "";
+ }
+
+ if ("url" in item) fields.url = item.url;
+ fields.name = resolvedModelName(item);
+
+ fields.type = "http";
+ if (item.model === "grpc_request") fields.type = "grpc";
+ else if (item.model === "websocket_request") fields.type = "ws";
+
+ if (node.parent?.item.model === "folder") {
+ fields.folder = node.parent.item.name;
+ }
+
+ return fields;
+}
+
+function getItemText(item: SidebarModel): string {
+ const segments = [];
+ if (item.model === "http_request") {
+ segments.push(item.method);
+ }
+
+ segments.push(resolvedModelName(item));
+
+ return segments.join(" ");
+}
diff --git a/apps/yaak-client/components/SidebarActions.tsx b/apps/yaak-client/components/SidebarActions.tsx
new file mode 100644
index 0000000..ba479a5
--- /dev/null
+++ b/apps/yaak-client/components/SidebarActions.tsx
@@ -0,0 +1,39 @@
+import { HStack } from "@yaakapp-internal/ui";
+import { useMemo } from "react";
+import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { CreateDropdown } from "./CreateDropdown";
+import { IconButton } from "./core/IconButton";
+
+interface Props {
+ floating?: boolean;
+}
+
+export function SidebarActions({ floating = false }: Props) {
+ const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
+ const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
+
+ const hidden = floating ? floatingHidden : sidebarHidden;
+ const setHidden = useMemo(
+ () => (floating ? setFloatingHidden : setSidebarHidden),
+ [floating, setFloatingHidden, setSidebarHidden],
+ );
+
+ return (
+
+ {
+ await setHidden(!hidden);
+ }}
+ className="pointer-events-auto"
+ size="sm"
+ title="Toggle sidebar"
+ icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
+ iconColor="secondary"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/SwitchWorkspaceDialog.tsx b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx
new file mode 100644
index 0000000..2852b8e
--- /dev/null
+++ b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx
@@ -0,0 +1,60 @@
+import type { Workspace } from "@yaakapp-internal/models";
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import { Button } from "./core/Button";
+import { Checkbox } from "./core/Checkbox";
+
+interface Props {
+ hide: () => void;
+ workspace: Workspace;
+}
+
+export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
+ const settings = useAtomValue(settingsAtom);
+ const [remember, setRemember] = useState(false);
+
+ return (
+
+
+ Where would you like to open {workspace.name} ?
+
+
+ {
+ hide();
+ switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
+ if (remember) {
+ await patchModel(settings, { openWorkspaceNewWindow: false });
+ }
+ }}
+ >
+ This Window
+
+ }
+ onClick={async () => {
+ hide();
+ switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
+ if (remember) {
+ await patchModel(settings, { openWorkspaceNewWindow: true });
+ }
+ }}
+ >
+ New Window
+
+
+ {settings && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/SyncToFilesystemSetting.tsx b/apps/yaak-client/components/SyncToFilesystemSetting.tsx
new file mode 100644
index 0000000..51e8132
--- /dev/null
+++ b/apps/yaak-client/components/SyncToFilesystemSetting.tsx
@@ -0,0 +1,121 @@
+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(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 (
+
+ {syncDir && (
+
+ Directory is not empty. Do you want to open it instead?
+
+ {
+ openWorkspaceFromSyncDir.mutate(syncDir);
+ onCreateNewWorkspace();
+ }}
+ >
+ Open Workspace
+
+
+
+ )}
+
+
+
+ {value.filePath && typeof value.initGit === "boolean" && (
+ onChange({ ...value, initGit })}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+ {syncDir && (
+
+ Directory is not empty. Do you want to open it instead?
+
+ {
+ openWorkspaceFromSyncDir.mutate(syncDir);
+ onCreateNewWorkspace();
+ }}
+ >
+ Open Workspace
+
+
+
+ )}
+
+ handleFilePathChange(filePath)}
+ />
+
+ {value.filePath && typeof value.initGit === "boolean" && (
+ onChange({ ...value, initGit })}
+ title="Initialize Git Repo"
+ />
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/TemplateFunctionDialog.tsx b/apps/yaak-client/components/TemplateFunctionDialog.tsx
new file mode 100644
index 0000000..92e2177
--- /dev/null
+++ b/apps/yaak-client/components/TemplateFunctionDialog.tsx
@@ -0,0 +1,296 @@
+import type { EditorView } from "@codemirror/view";
+import type {
+ Folder,
+ GrpcRequest,
+ HttpRequest,
+ WebsocketRequest,
+ Workspace,
+} from "@yaakapp-internal/models";
+import type { TemplateFunction } from "@yaakapp-internal/plugins";
+import type { FnArg, Tokens } from "@yaakapp-internal/templates";
+import { parseTemplate } from "@yaakapp-internal/templates";
+import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useEffect, useMemo, useState } from "react";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { useRenderTemplate } from "../hooks/useRenderTemplate";
+import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
+import {
+ templateTokensToString,
+ useTemplateTokensToString,
+} from "../hooks/useTemplateTokensToString";
+import { useToggle } from "../hooks/useToggle";
+import { showDialog } from "../lib/dialog";
+import { convertTemplateToInsecure } from "../lib/encryption";
+import { jotaiStore } from "../lib/jotai";
+import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
+import { Button } from "./core/Button";
+import { collectArgumentValues } from "./core/Editor/twig/util";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
+
+interface Props {
+ templateFunction: TemplateFunction;
+ initialTokens: Tokens;
+ hide: () => void;
+ onChange: (insert: string) => void;
+ model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
+}
+
+export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
+ const [initialArgValues, setInitialArgValues] = useState | null>(
+ null,
+ );
+ useEffect(() => {
+ if (initialArgValues != null) {
+ return;
+ }
+
+ (async () => {
+ const initial = collectArgumentValues(initialTokens, templateFunction);
+
+ // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
+ // we can display it in the editor input.
+ if (templateFunction.name === "secure") {
+ const template = await templateTokensToString(initialTokens);
+ initial.value = await convertTemplateToInsecure(template);
+ }
+
+ setInitialArgValues(initial);
+ })().catch(console.error);
+ }, [
+ initialArgValues,
+ initialTokens,
+ initialTokens.tokens,
+ templateFunction,
+ templateFunction.args,
+ templateFunction.name,
+ ]);
+
+ if (initialArgValues == null) return null;
+
+ return (
+
+ );
+}
+
+function InitializedTemplateFunctionDialog({
+ templateFunction: { name, previewType: ogPreviewType },
+ initialArgValues,
+ hide,
+ onChange,
+ model,
+}: Omit & {
+ initialArgValues: Record;
+}) {
+ const previewType = ogPreviewType == null ? "live" : ogPreviewType;
+ const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
+ const [argValues, setArgValues] = useState>(initialArgValues);
+
+ const tokens: Tokens = useMemo(() => {
+ const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
+ name,
+ value:
+ argValues[name] === DYNAMIC_FORM_NULL_ARG
+ ? { type: "null" }
+ : typeof argValues[name] === "boolean"
+ ? { type: "bool", value: argValues[name] === true }
+ : { type: "str", text: String(argValues[name] ?? "") },
+ }));
+
+ return {
+ tokens: [
+ {
+ type: "tag",
+ val: {
+ type: "fn",
+ name,
+ args: argTokens,
+ },
+ },
+ ],
+ };
+ }, [argValues, name]);
+
+ const tagText = useTemplateTokensToString(tokens);
+ const templateFunction = useTemplateFunctionConfig(name, argValues, model);
+
+ const handleDone = () => {
+ if (tagText.data) {
+ onChange(tagText.data);
+ }
+ hide();
+ };
+
+ const debouncedTagText = useDebouncedValue(tagText.data ?? "", 400);
+ const [renderKey, setRenderKey] = useState(null);
+ const rendered = useRenderTemplate({
+ template: debouncedTagText,
+ enabled: previewType !== "none",
+ purpose: previewType === "click" ? "send" : "preview",
+ refreshKey: previewType === "live" ? renderKey + debouncedTagText : renderKey,
+ ignoreError: false,
+ });
+
+ const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change
+ const dataContainsSecrets = useMemo(() => {
+ for (const [name, value] of Object.entries(argValues)) {
+ const arg = templateFunction.data?.args.find((a) => "name" in a && a.name === name);
+ const isTextPassword = arg?.type === "text" && arg.password;
+ if (isTextPassword && typeof value === "string" && value && rendered.data?.includes(value)) {
+ return true;
+ }
+ }
+ return false;
+ }, [rendered.data]);
+
+ if (templateFunction.data == null || templateFunction.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ {
+ e.preventDefault();
+ handleDone();
+ }}
+ >
+
+ {name === "secure" ? (
+
setArgValues({ ...argValues, value })}
+ />
+ ) : (
+
+ )}
+
+
+ {previewType !== "none" ? (
+
+
+
+ Rendered Preview
+ {rendered.isLoading && }
+
+
+
+
+
+ {rendered.error || tagText.error ? (
+
+ {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
+
+ ) : dataContainsSecrets && !showSecretsInPreview ? (
+
+ ------ sensitive values hidden ------
+
+ ) : tooLarge ? (
+ "too large to preview"
+ ) : (
+ rendered.data || <> >
+ )}
+
+
+ {
+ setRenderKey(new Date().toISOString());
+ }}
+ />
+
+
+
+ ) : (
+
+ )}
+
+ {templateFunction.data.name === "secure" && (
+
+ Reveal Encryption Key
+
+ )}
+
+ Save
+
+
+
+
+ );
+}
+
+TemplateFunctionDialog.show = (
+ fn: TemplateFunction,
+ tagValue: string,
+ startPos: number,
+ view: EditorView,
+) => {
+ const initialTokens = parseTemplate(tagValue);
+ showDialog({
+ id: `template-function-${Math.random()}`, // Allow multiple at once
+ size: "md",
+ className: "h-240",
+ noPadding: true,
+ title: {fn.name}(…) ,
+ description: fn.description,
+ render: ({ hide }) => {
+ const model = jotaiStore.get(activeWorkspaceAtom);
+ if (model == null) return null;
+ return (
+ {
+ view.dispatch({
+ changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
+ });
+ }}
+ />
+ );
+ },
+ });
+};
diff --git a/apps/yaak-client/components/Toasts.tsx b/apps/yaak-client/components/Toasts.tsx
new file mode 100644
index 0000000..046f191
--- /dev/null
+++ b/apps/yaak-client/components/Toasts.tsx
@@ -0,0 +1,43 @@
+import { useAtomValue } from "jotai";
+import { AnimatePresence } from "motion/react";
+import type { ReactNode } from "react";
+import { hideToast, toastsAtom } from "../lib/toast";
+import { Toast, type ToastProps } from "./core/Toast";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { Portal } from "@yaakapp-internal/ui";
+
+export type ToastInstance = {
+ id: string;
+ uniqueKey: string;
+ message: ReactNode;
+ timeout: 3000 | 5000 | 8000 | (number & {}) | null;
+ onClose?: ToastProps["onClose"];
+} & Omit;
+
+export const Toasts = () => {
+ const toasts = useAtomValue(toastsAtom);
+ return (
+
+
+
+ {toasts.map((toast: ToastInstance) => {
+ const { message, uniqueKey, ...props } = toast;
+ return (
+
+ hideToast(toast)}
+ >
+ {message}
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/yaak-client/components/UrlBar.tsx b/apps/yaak-client/components/UrlBar.tsx
new file mode 100644
index 0000000..b729ad3
--- /dev/null
+++ b/apps/yaak-client/components/UrlBar.tsx
@@ -0,0 +1,113 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import type { IconProps } from "@yaakapp-internal/ui";
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { FormEvent, ReactNode } from "react";
+import { memo, useCallback, useRef, useState } from "react";
+import { useHotKey } from "../hooks/useHotKey";
+import { IconButton } from "./core/IconButton";
+import type { InputHandle, InputProps } from "./core/Input";
+import { Input } from "./core/Input";
+
+type Props = Pick & {
+ className?: string;
+ placeholder: string;
+ onSend: () => void;
+ onUrlChange: (url: string) => void;
+ onPaste?: (v: string) => void;
+ onPasteOverwrite?: InputProps["onPasteOverwrite"];
+ onCancel: () => void;
+ submitIcon?: IconProps["icon"] | null;
+ isLoading: boolean;
+ forceUpdateKey: string;
+ rightSlot?: ReactNode;
+ leftSlot?: ReactNode;
+ autocomplete?: InputProps["autocomplete"];
+ stateKey: InputProps["stateKey"];
+};
+
+export const UrlBar = memo(function UrlBar({
+ forceUpdateKey,
+ onUrlChange,
+ url,
+ placeholder,
+ className,
+ onSend,
+ onCancel,
+ onPaste,
+ onPasteOverwrite,
+ submitIcon = "send_horizontal",
+ autocomplete,
+ leftSlot,
+ rightSlot,
+ isLoading,
+ stateKey,
+}: Props) {
+ const inputRef = useRef(null);
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleInitInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
+ useHotKey("url_bar.focus", () => {
+ inputRef.current?.selectAll();
+ });
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (isLoading) onCancel();
+ else onSend();
+ };
+
+ return (
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ onPaste={onPaste}
+ onPasteOverwrite={onPasteOverwrite}
+ onChange={onUrlChange}
+ defaultValue={url}
+ placeholder={placeholder}
+ leftSlot={leftSlot}
+ rightSlot={
+
+ {rightSlot && {rightSlot}
}
+ {submitIcon !== null && (
+
+ {
+ // Prevent the button from taking focus
+ e.preventDefault();
+ }}
+ />
+
+ )}
+
+ }
+ />
+
+ );
+});
diff --git a/apps/yaak-client/components/UrlParameterEditor.tsx b/apps/yaak-client/components/UrlParameterEditor.tsx
new file mode 100644
index 0000000..aab2afa
--- /dev/null
+++ b/apps/yaak-client/components/UrlParameterEditor.tsx
@@ -0,0 +1,54 @@
+import { VStack } from "@yaakapp-internal/ui";
+import { useCallback, useRef } from "react";
+import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
+import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
+import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
+
+type Props = {
+ forceUpdateKey: string;
+ pairs: EditablePair[];
+ stateKey: PairEditorProps["stateKey"];
+ onChange: PairEditorProps["onChange"];
+};
+
+export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
+ const pairEditorRef = useRef(null);
+ const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
+ pairEditorRef.current = ref;
+ }, []);
+
+ const [{ urlParametersKey }] = useRequestEditor();
+
+ useRequestEditorEvent(
+ "request_params.focus_value",
+ (name) => {
+ const pair = pairs.find((p) => p.name === name);
+ if (pair?.id != null) {
+ pairEditorRef.current?.focusValue(pair.id);
+ } else {
+ console.log(`Couldn't find pair to focus`, { name, pairs });
+ }
+ },
+ [pairs],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketRequestLayout.tsx b/apps/yaak-client/components/WebsocketRequestLayout.tsx
new file mode 100644
index 0000000..16e6b3e
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketRequestLayout.tsx
@@ -0,0 +1,49 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import type { CSSProperties } from "react";
+
+import { SplitLayout } from "@yaakapp-internal/ui";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { workspaceLayoutAtom } from "../lib/atoms";
+import { WebsocketRequestPane } from "./WebsocketRequestPane";
+import { WebsocketResponsePane } from "./WebsocketResponsePane";
+
+interface Props {
+ activeRequest: WebsocketRequest;
+ style: CSSProperties;
+}
+
+export function WebsocketRequestLayout({ activeRequest, style }: Props) {
+ const workspaceLayout = useAtomValue(workspaceLayoutAtom);
+ const activeWorkspace = useAtomValue(activeWorkspaceAtom);
+ const wsId = activeWorkspace?.id ?? "n/a";
+ return (
+ (
+
+ )}
+ secondSlot={({ style }) => (
+
+
+
+ )}
+ />
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx
new file mode 100644
index 0000000..26ece54
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketRequestPane.tsx
@@ -0,0 +1,314 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import {
+ flushAllModelWrites,
+ getModel,
+ patchModel,
+ patchModelDebounced,
+} from "@yaakapp-internal/models";
+import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
+import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
+import classNames from "classnames";
+import { atom, useAtomValue } from "jotai";
+import type { CSSProperties } from "react";
+import { useCallback, useMemo, useRef } from "react";
+import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
+import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
+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 { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
+import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
+import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
+import { languageFromContentType } from "../lib/contentType";
+import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
+import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
+import { resolvedModelName } from "../lib/resolvedModelName";
+import { CountBadge } from "./core/CountBadge";
+import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
+import { getUrlCompletionConfig } from "./core/Editor/url/completion";
+import { Editor } from "./core/Editor/LazyEditor";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
+import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
+import { HeadersEditor } from "./HeadersEditor";
+import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
+import { MarkdownEditor } from "./MarkdownEditor";
+import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
+import { UrlBar } from "./UrlBar";
+import { UrlParametersEditor } from "./UrlParameterEditor";
+
+interface Props {
+ style: CSSProperties;
+ fullHeight: boolean;
+ className?: string;
+ activeRequest: WebsocketRequest;
+}
+
+const TAB_MESSAGE = "message";
+const TAB_PARAMS = "params";
+const TAB_HEADERS = "headers";
+const TAB_AUTH = "auth";
+const TAB_SETTINGS = "settings";
+const TAB_DESCRIPTION = "description";
+const TABS_STORAGE_KEY = "websocket_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 WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
+ const activeRequestId = activeRequest.id;
+ const tabsRef = useRef(null);
+ const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
+ const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
+ 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);
+ },
+ [],
+ );
+
+ // 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("websocket_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],
+ );
+
+ const hasDescription = activeRequest.description.trim().length > 0;
+
+ const tabs = useMemo(() => {
+ return [
+ {
+ value: TAB_MESSAGE,
+ label: "Message",
+ } as TabItem,
+ {
+ value: TAB_PARAMS,
+ rightSlot: ,
+ label: "Params",
+ },
+ ...headersTab,
+ ...authTab,
+ {
+ value: TAB_SETTINGS,
+ label: "Settings",
+ rightSlot: ,
+ },
+ {
+ value: TAB_DESCRIPTION,
+ label: "Info",
+ rightSlot: hasDescription && ,
+ },
+ ];
+ }, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
+
+ const { activeResponse } = usePinnedHttpResponse(activeRequestId);
+ const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
+ const connection = useAtomValue(activeWebsocketConnectionAtom);
+
+ const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
+
+ const autocomplete: GenericCompletionConfig = useMemo(
+ () => getUrlCompletionConfig(autocompleteUrls),
+ [autocompleteUrls],
+ );
+
+ const handleConnect = useCallback(async () => {
+ await flushAllModelWrites(); // The backend reads the request from the DB
+ await connectWebsocket({
+ requestId: activeRequest.id,
+ environmentId: getActiveEnvironment()?.id ?? null,
+ cookieJarId: getActiveCookieJar()?.id ?? null,
+ });
+ }, [activeRequest.id]);
+
+ const handleSend = useCallback(async () => {
+ if (connection == null) return;
+ await flushAllModelWrites(); // The backend reads the message from the DB
+ await sendWebsocket({
+ connectionId: connection?.id,
+ environmentId: getActiveEnvironment()?.id ?? null,
+ });
+ }, [connection]);
+
+ const handleCancel = useCallback(async () => {
+ if (connection == null) return;
+ await closeWebsocket({ connectionId: connection?.id });
+ }, [connection]);
+
+ const handleUrlChange = useCallback(
+ (url: string) => patchModelDebounced(activeRequest, { url }),
+ [activeRequest],
+ );
+
+ const handlePaste = useCallback(
+ async (e: ClipboardEvent, text: string) => {
+ 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 messageLanguage = languageFromContentType(null, activeRequest.message);
+
+ const isLoading = connection !== null && connection.state !== "closed";
+
+ return (
+
+ {activeRequest && (
+ <>
+
+
+ )
+ }
+ placeholder="wss://example.com"
+ onPasteOverwrite={handlePaste}
+ autocomplete={autocomplete}
+ onSend={isLoading ? handleSend : handleConnect}
+ onCancel={cancelResponse}
+ onUrlChange={handleUrlChange}
+ forceUpdateKey={forceUpdateKey}
+ isLoading={activeResponse != null && activeResponse.state !== "closed"}
+ />
+
+
+
+
+
+
+ patchModelDebounced(activeRequest, { headers })}
+ />
+
+
+ patchModelDebounced(activeRequest, { urlParameters })}
+ />
+
+
+ patchModelDebounced(activeRequest, { message })}
+ stateKey={`json.${activeRequest.id}`}
+ />
+
+
+
+
+
+
+
patchModelDebounced(activeRequest, { name })}
+ />
+ patchModelDebounced(activeRequest, { description })}
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketResponsePane.tsx b/apps/yaak-client/components/WebsocketResponsePane.tsx
new file mode 100644
index 0000000..42806e4
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketResponsePane.tsx
@@ -0,0 +1,241 @@
+import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
+import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
+import { hexy } from "hexy";
+import { useAtomValue } from "jotai";
+import { useMemo, useState } from "react";
+import { useFormatText } from "../hooks/useFormatText";
+import {
+ activeWebsocketConnectionAtom,
+ activeWebsocketConnectionsAtom,
+ setPinnedWebsocketConnectionId,
+ useWebsocketEvents,
+} from "../hooks/usePinnedWebsocketConnection";
+import { useStateWithDeps } from "../hooks/useStateWithDeps";
+import { languageFromContentType } from "../lib/contentType";
+import { Button } from "./core/Button";
+import { Editor } from "./core/Editor/LazyEditor";
+import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
+import { EventViewerRow } from "./core/EventViewerRow";
+import { HotkeyList } from "./core/HotkeyList";
+import { WebsocketStatusTag } from "./core/WebsocketStatusTag";
+import { EmptyStateText } from "./EmptyStateText";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { RecentWebsocketConnectionsDropdown } from "./RecentWebsocketConnectionsDropdown";
+
+interface Props {
+ activeRequest: WebsocketRequest;
+}
+
+export function WebsocketResponsePane({ activeRequest }: Props) {
+ const [showLarge, setShowLarge] = useStateWithDeps(false, [activeRequest.id]);
+ const [showingLarge, setShowingLarge] = useState(false);
+ const [hexDumps, setHexDumps] = useState>({});
+
+ const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
+ const connections = useAtomValue(activeWebsocketConnectionsAtom);
+ const events = useWebsocketEvents(activeConnection?.id ?? null);
+
+ if (activeConnection == null) {
+ return (
+
+ );
+ }
+
+ const header = (
+
+
+ {activeConnection.state !== "closed" && (
+
+ )}
+
+ •
+ {events.length} Messages
+
+
+
+
+
+ );
+
+ return (
+
+ event.id}
+ error={activeConnection.error}
+ header={header}
+ splitLayoutStorageKey="websocket_events"
+ defaultRatio={0.4}
+ renderRow={({ event, isActive, onClick }) => (
+
+ )}
+ renderDetail={({ event, index, onClose }) => (
+ setHexDumps({ ...hexDumps, [index]: v })}
+ showLarge={showLarge}
+ showingLarge={showingLarge}
+ setShowLarge={setShowLarge}
+ setShowingLarge={setShowingLarge}
+ onClose={onClose}
+ />
+ )}
+ />
+
+ );
+}
+
+function WebsocketEventRow({
+ event,
+ isActive,
+ onClick,
+}: {
+ event: WebsocketEvent;
+ isActive: boolean;
+ onClick: () => void;
+}) {
+ const { message: messageBytes, isServer, messageType } = event;
+ const message = messageBytes
+ ? new TextDecoder("utf-8").decode(Uint8Array.from(messageBytes))
+ : "";
+
+ const iconColor =
+ messageType === "error"
+ ? "warning"
+ : messageType === "close" || messageType === "open"
+ ? "secondary"
+ : isServer
+ ? "info"
+ : "primary";
+
+ const icon =
+ messageType === "error"
+ ? "alert_triangle"
+ : messageType === "close" || messageType === "open"
+ ? "info"
+ : isServer
+ ? "arrow_big_down_dash"
+ : "arrow_big_up_dash";
+
+ const content =
+ messageType === "close" ? (
+ "Disconnected from server"
+ ) : messageType === "open" ? (
+ "Connected to server"
+ ) : messageType === "error" ? (
+ {message}
+ ) : message === "" ? (
+ No content
+ ) : (
+ {message.slice(0, 1000)}
+ );
+
+ return (
+ }
+ content={content}
+ timestamp={event.createdAt}
+ />
+ );
+}
+
+function WebsocketEventDetail({
+ event,
+ hexDump,
+ setHexDump,
+ showLarge,
+ showingLarge,
+ setShowLarge,
+ setShowingLarge,
+ onClose,
+}: {
+ event: WebsocketEvent;
+ hexDump: boolean;
+ setHexDump: (v: boolean) => void;
+ showLarge: boolean;
+ showingLarge: boolean;
+ setShowLarge: (v: boolean) => void;
+ setShowingLarge: (v: boolean) => void;
+ onClose: () => void;
+}) {
+ const message = useMemo(() => {
+ if (hexDump) {
+ return event.message ? hexy(event.message) : "";
+ }
+ return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
+ }, [event.message, hexDump]);
+
+ const language = languageFromContentType(null, message);
+ const formattedMessage = useFormatText({ language, text: message, pretty: true });
+
+ const title =
+ event.messageType === "close"
+ ? "Connection Closed"
+ : event.messageType === "open"
+ ? "Connection Open"
+ : event.messageType === "error"
+ ? "WebSocket Error"
+ : `Message ${event.isServer ? "Received" : "Sent"}`;
+
+ const actions: EventDetailAction[] =
+ message !== ""
+ ? [
+ {
+ key: "toggle-hexdump",
+ label: hexDump ? "Show Message" : "Show Hexdump",
+ onClick: () => setHexDump(!hexDump),
+ },
+ ]
+ : [];
+
+ return (
+
+
+ {!showLarge && event.message.length > 1000 * 1000 ? (
+
+ Message previews larger than 1MB are hidden
+
+ {
+ setShowingLarge(true);
+ setTimeout(() => {
+ setShowLarge(true);
+ setShowingLarge(false);
+ }, 500);
+ }}
+ isLoading={showingLarge}
+ color="secondary"
+ variant="border"
+ size="xs"
+ >
+ Try Showing
+
+
+
+ ) : event.message.length === 0 ? (
+
No Content
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/Workspace.tsx b/apps/yaak-client/components/Workspace.tsx
new file mode 100644
index 0000000..1e6d22c
--- /dev/null
+++ b/apps/yaak-client/components/Workspace.tsx
@@ -0,0 +1,227 @@
+import { platform } from "@yaakapp-internal/platform";
+import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
+import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import * as m from "motion/react-m";
+import { useMemo, useState } from "react";
+import {
+ getActiveCookieJar,
+ useEnsureActiveCookieJar,
+ useSubscribeActiveCookieJarId,
+} from "../hooks/useActiveCookieJar";
+import {
+ activeEnvironmentAtom,
+ useSubscribeActiveEnvironmentId,
+} from "../hooks/useActiveEnvironment";
+import { activeFolderAtom } from "../hooks/useActiveFolder";
+import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
+import { activeRequestAtom } from "../hooks/useActiveRequest";
+import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
+import { useHotKey } from "../hooks/useHotKey";
+import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
+import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
+import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { useSidebarWidth } from "../hooks/useSidebarWidth";
+import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
+import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
+import { importData } from "../lib/importData";
+import { jotaiStore } from "../lib/jotai";
+import { CreateDropdown } from "./CreateDropdown";
+import { Button } from "./core/Button";
+import { HotkeyList } from "./core/HotkeyList";
+import { CookieDialog } from "./CookieDialog";
+import { FeedbackLink } from "./core/Link";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { FolderLayout } from "./FolderLayout";
+import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
+import { HttpRequestLayout } from "./HttpRequestLayout";
+import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
+import Sidebar from "./Sidebar";
+import { SidebarActions } from "./SidebarActions";
+import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
+import { WorkspaceHeader } from "./WorkspaceHeader";
+
+const body = { gridArea: "body" };
+
+export function Workspace() {
+ // First, subscribe to some things applicable to workspaces
+ useGlobalWorkspaceHooks();
+
+ const workspaces = useAtomValue(workspacesAtom);
+ const settings = useAtomValue(settingsAtom);
+ const osType = platform.osType();
+ const [width, setWidth] = useSidebarWidth();
+ const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
+ const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
+ const activeEnvironment = useAtomValue(activeEnvironmentAtom);
+ const [floating, setFloating] = useState(false);
+
+ const environmentBgStyle = useMemo(() => {
+ if (activeEnvironment?.color == null) return undefined;
+ const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
+ return { background };
+ }, [activeEnvironment?.color]);
+
+ if (workspaces.length === 0) {
+ return ;
+ }
+
+ const header = (
+
+
+
+
+ );
+
+ const workspaceBody = (
+
+
+
+ );
+
+ const sidebarContent = floating ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ );
+
+ return (
+
+ {header}
+ setSidebarHidden(hidden)}
+ floatingHidden={floatingSidebarHidden ?? true}
+ onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
+ onFloatingChange={setFloating}
+ sidebar={sidebarContent}
+ >
+ {workspaceBody}
+
+
+ );
+}
+
+function WorkspaceBody() {
+ const activeRequest = useAtomValue(activeRequestAtom);
+ const activeFolder = useAtomValue(activeFolderAtom);
+ const activeWorkspace = useAtomValue(activeWorkspaceAtom);
+
+ if (activeWorkspace == null) {
+ return (
+
+
+ The active workspace was not found. Select a workspace from the header menu or report this
+ bug to
+
+
+ );
+ }
+
+ if (activeRequest?.model === "grpc_request") {
+ return ;
+ }
+ if (activeRequest?.model === "websocket_request") {
+ return ;
+ }
+ if (activeRequest?.model === "http_request") {
+ return ;
+ }
+ if (activeFolder != null) {
+ return ;
+ }
+
+ return (
+
+ importData.mutate()}>
+ Import
+
+
+
+ New Request
+
+
+
+ }
+ />
+ );
+}
+
+function useGlobalWorkspaceHooks() {
+ useEnsureActiveCookieJar();
+
+ useSubscribeActiveRequestId();
+ useSubscribeActiveFolderId();
+ useSubscribeActiveEnvironmentId();
+ useSubscribeActiveCookieJarId();
+
+ useSubscribeRecentRequests();
+ useSubscribeRecentWorkspaces();
+ useSubscribeRecentEnvironments();
+ useSubscribeRecentCookieJars();
+
+ useSyncWorkspaceRequestTitle();
+
+ useHotKey("model.duplicate", () =>
+ duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
+ );
+
+ useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
+ enable: () => getActiveCookieJar() != null,
+ });
+}
diff --git a/apps/yaak-client/components/WorkspaceActionsDropdown.tsx b/apps/yaak-client/components/WorkspaceActionsDropdown.tsx
new file mode 100644
index 0000000..1731b74
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceActionsDropdown.tsx
@@ -0,0 +1,193 @@
+import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { memo, useCallback, useMemo } from "react";
+import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
+import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import {
+ activeWorkspaceAtom,
+ activeWorkspaceIdAtom,
+ activeWorkspaceMetaAtom,
+} from "../hooks/useActiveWorkspace";
+import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
+import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
+import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
+import { showDialog } from "../lib/dialog";
+import { importData } from "../lib/importData";
+import { jotaiStore } from "../lib/jotai";
+import { revealInFinderText } from "../lib/reveal";
+import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
+import type { ButtonProps } from "./core/Button";
+import { Button } from "./core/Button";
+import type { DropdownItem } from "./core/Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+import type { RadioDropdownItem } from "./core/RadioDropdown";
+import { RadioDropdown } from "./core/RadioDropdown";
+import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
+import { platform } from "@yaakapp-internal/platform";
+
+type Props = Pick;
+
+export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
+ className,
+ ...buttonProps
+}: Props) {
+ const workspaces = useAtomValue(workspacesAtom);
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const createWorkspace = useCreateWorkspace();
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const { mutate: deleteSendHistory } = useDeleteSendHistory();
+ const workspaceActions = useWorkspaceActions();
+
+ const openCloneGitRepositoryDialog = useCallback(() => {
+ showDialog({
+ id: "clone-git-repository",
+ size: "md",
+ title: "Clone Git Repository",
+ render: ({ hide }) => ,
+ });
+ }, []);
+
+ const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
+ workspaceItems: RadioDropdownItem[];
+ itemsAfter: DropdownItem[];
+ itemsBefore: DropdownItem[];
+ }>(() => {
+ const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
+ key: w.id,
+ label: w.name,
+ value: w.id,
+ leftSlot: w.id === workspace?.id ? : ,
+ }));
+
+ const itemsBefore: DropdownItem[] = [
+ {
+ label: "New Workspace",
+ leftSlot: ,
+ submenu: [
+ {
+ label: "Create Empty",
+ leftSlot: ,
+ onSelect: createWorkspace,
+ },
+ {
+ label: "Open Folder",
+ leftSlot: ,
+ onSelect: async () => {
+ const dir = await platform.dialog.open({
+ title: "Select Workspace Directory",
+ directory: true,
+ multiple: false,
+ });
+
+ if (dir == null) return;
+ openWorkspaceFromSyncDir.mutate(dir);
+ },
+ },
+ {
+ label: "Clone Git Repository",
+ leftSlot: ,
+ onSelect: openCloneGitRepositoryDialog,
+ },
+ {
+ label: "Import Data",
+ leftSlot: ,
+ onSelect: () => importData.mutate(),
+ },
+ ],
+ },
+ ];
+ const itemsAfter: DropdownItem[] = [
+ ...workspaceActions.map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ if (workspace != null) await a.call(workspace);
+ },
+ })),
+ ...(workspaceActions.length > 0 ? [{ type: "separator" as const }] : []),
+ {
+ label: "Workspace Settings",
+ leftSlot: ,
+ hotKeyAction: "workspace_settings.show",
+ onSelect: openWorkspaceSettings,
+ },
+ {
+ label: revealInFinderText,
+ hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
+ leftSlot: ,
+ onSelect: async () => {
+ if (workspaceMeta?.settingSyncDir == null) return;
+ await platform.revealItemInDir(workspaceMeta.settingSyncDir);
+ },
+ },
+ {
+ label: "Clear Send History",
+ color: "warning",
+ leftSlot: ,
+ onSelect: deleteSendHistory,
+ },
+ ];
+
+ return { workspaceItems, itemsAfter, itemsBefore };
+ }, [
+ workspaces,
+ workspaceMeta,
+ deleteSendHistory,
+ createWorkspace,
+ openCloneGitRepositoryDialog,
+ workspace?.id,
+ workspace,
+ workspaceActions.map,
+ workspaceActions.length,
+ ]);
+
+ const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
+ if (workspaceId == null) return;
+
+ const settings = jotaiStore.get(settingsAtom);
+ const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId === activeWorkspaceId) {
+ // Always open a new window if the selected one is already active
+ switchWorkspace.mutate({ workspaceId, inNewWindow: true });
+ return;
+ }
+ if (typeof settings.openWorkspaceNewWindow === "boolean") {
+ switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
+ return;
+ }
+
+ const workspace = getModel("workspace", workspaceId);
+ if (workspace == null) return;
+
+ showDialog({
+ id: "switch-workspace",
+ size: "sm",
+ title: "Switch Workspace",
+ render: ({ hide }) => ,
+ });
+ }, []);
+
+ return (
+
+
+ {workspace?.name ?? "Workspace"}
+
+
+ );
+});
diff --git a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx
new file mode 100644
index 0000000..43b88ab
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx
@@ -0,0 +1,356 @@
+import {
+ disableEncryption,
+ enableEncryption,
+ revealWorkspaceKey,
+ setWorkspaceKey,
+} from "@yaakapp-internal/crypto";
+import type { WorkspaceMeta } from "@yaakapp-internal/models";
+import { Banner, HStack, VStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { useEffect, useState } from "react";
+import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { useStateWithDeps } from "../hooks/useStateWithDeps";
+import { showConfirm } from "../lib/confirm";
+import { CopyIconButton } from "./CopyIconButton";
+import type { ButtonProps } from "./core/Button";
+import { Button } from "./core/Button";
+import { IconButton } from "./core/IconButton";
+import { IconTooltip } from "./core/IconTooltip";
+import { Label } from "./core/Label";
+import { PlainInput } from "./core/PlainInput";
+import { SettingRow } from "./core/SettingRow";
+import { EncryptionHelp } from "./EncryptionHelp";
+
+interface Props {
+ layout?: "form" | "settings";
+ size?: ButtonProps["size"];
+ expanded?: boolean;
+ onDone?: () => void;
+ onEnabledEncryption?: () => void;
+}
+
+export function WorkspaceEncryptionSetting({
+ layout = "form",
+ size,
+ expanded,
+ onDone,
+ onEnabledEncryption,
+}: Props) {
+ const [justEnabledEncryption, setJustEnabledEncryption] = useState(false);
+ const [error, setError] = useState(null);
+
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
+
+ useEffect(() => {
+ if (workspaceMeta == null) {
+ return;
+ }
+
+ if (workspaceMeta?.encryptionKey == null) {
+ setKey({ key: null, error: null });
+ return;
+ }
+
+ revealWorkspaceKey(workspaceMeta.workspaceId).then(
+ (key) => {
+ setKey({ key, error: null });
+ },
+ (err) => {
+ setKey({ key: null, error: `${err}` });
+ },
+ );
+ }, [workspaceMeta, workspaceMeta?.encryptionKey]);
+
+ if (key == null || workspace == null || workspaceMeta == null) {
+ return null;
+ }
+
+ // Prompt for key if it doesn't exist or could not be decrypted
+ if (
+ key.error != null ||
+ (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
+ ) {
+ const enterKey = (
+ {
+ onDone?.();
+ onEnabledEncryption?.();
+ }}
+ onDisabled={() => {
+ onDone?.();
+ }}
+ />
+ );
+
+ return enterKey;
+ }
+
+ // Show the key if it exists
+ if (workspaceMeta.encryptionKey && key.key != null) {
+ const keyRevealer = (
+
+ );
+
+ const content = (
+
+ {justEnabledEncryption && (
+
+ {helpAfterEncryption}
+
+ )}
+ {keyRevealer}
+ {onDone && (
+ {
+ onDone();
+ onEnabledEncryption?.();
+ }}
+ >
+ Done
+
+ )}
+
+ );
+
+ return content;
+ }
+
+ // Show button to enable encryption
+ if (layout === "settings") {
+ return (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+
+ {
+ setError(null);
+ try {
+ await enableEncryption(workspaceMeta.workspaceId);
+ setJustEnabledEncryption(true);
+ } catch (err) {
+ setError(`Failed to enable encryption: ${String(err)}`);
+ }
+ }}
+ >
+ Enable Encryption
+
+
+ >
+ );
+ }
+
+ return (
+
+ {
+ setError(null);
+ try {
+ await enableEncryption(workspaceMeta.workspaceId);
+ setJustEnabledEncryption(true);
+ } catch (err) {
+ setError(`Failed to enable encryption: ${String(err)}`);
+ }
+ }}
+ >
+ Enable Encryption
+
+ {error && (
+
+ {error}
+
+ )}
+ {expanded ? (
+
+
+
+ ) : (
+ }>
+ Workspace encryption
+
+ )}
+
+ );
+}
+
+const setWorkspaceKeyMut = createFastMutation({
+ mutationKey: ["set-workspace-key"],
+ mutationFn: setWorkspaceKey,
+});
+
+function EnterWorkspaceKey({
+ workspaceMeta,
+ onEnabled,
+ onDisabled,
+ error,
+}: {
+ workspaceMeta: WorkspaceMeta;
+ onEnabled?: () => void;
+ onDisabled?: () => void;
+ error?: string | null;
+}) {
+ const [key, setKey] = useState("");
+
+ const handleForgotKey = async () => {
+ const confirmed = await showConfirm({
+ id: "disable-encryption",
+ title: "Disable Encryption",
+ color: "danger",
+ confirmText: "Disable Encryption",
+ description: (
+ <>
+ This will disable encryption for this workspace. Any previously encrypted values will fail
+ to decrypt and will need to be re-entered manually.
+
+
+ This action cannot be undone.
+ >
+ ),
+ });
+
+ if (confirmed) {
+ await disableEncryption(workspaceMeta.workspaceId);
+ onDisabled?.();
+ }
+ };
+
+ return (
+
+ {error ? (
+ {error}
+ ) : (
+
+ This workspace contains encrypted values but no key is configured. Please enter the
+ workspace key to access the encrypted data.
+
+ )}
+ {
+ e.preventDefault();
+ setWorkspaceKeyMut.mutate(
+ {
+ workspaceId: workspaceMeta.workspaceId,
+ key: key.trim(),
+ },
+ { onSuccess: onEnabled },
+ );
+ }}
+ >
+
+
+ Submit
+
+
+
+ Forgot your key?
+
+
+ );
+}
+
+function KeyRevealer({
+ defaultShow = false,
+ disableLabel = false,
+ encryptionKey,
+}: {
+ defaultShow?: boolean;
+ disableLabel?: boolean;
+ encryptionKey: string;
+}) {
+ const [show, setShow] = useStateWithDeps(defaultShow, [defaultShow]);
+
+ return (
+
+
+ {!disableLabel && (
+
+ Workspace encryption key{" "}
+
+
+ )}
+ {encryptionKey && }
+
+
+ {encryptionKey && }
+ setShow((v) => !v)}
+ />
+
+
+ );
+}
+
+function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
+ return (
+
+ {show ? (
+ keyText.split("").map((c, i) => {
+ return (
+
+ {c}
+
+ );
+ })
+ ) : (
+ •••••••••••••••••••••
+ )}
+
+ );
+}
+
+const helpAfterEncryption = (
+
+ The following key is used for encryption operations within this workspace. It is stored securely
+ using your OS keychain, but it is recommended to back it up. If you share this workspace with
+ others, you'll need to send them this key to access any encrypted values.
+
+);
diff --git a/apps/yaak-client/components/WorkspaceHeader.tsx b/apps/yaak-client/components/WorkspaceHeader.tsx
new file mode 100644
index 0000000..aefe5f4
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceHeader.tsx
@@ -0,0 +1,94 @@
+import { HStack, Icon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtom, useAtomValue } from "jotai";
+import { memo } from "react";
+import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
+import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
+import { workspaceLayoutAtom } from "../lib/atoms";
+import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
+import { CookieDropdown } from "./CookieDropdown";
+import { IconButton } from "./core/IconButton";
+import { PillButton } from "./core/PillButton";
+import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
+import { ImportCurl } from "./ImportCurl";
+import { LicenseBadge } from "./LicenseBadge";
+import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
+import { SettingsDropdown } from "./SettingsDropdown";
+import { SidebarActions } from "./SidebarActions";
+import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
+
+interface Props {
+ className?: string;
+ floatingSidebar?: boolean;
+}
+
+export const WorkspaceHeader = memo(function WorkspaceHeader({
+ className,
+ floatingSidebar,
+}: Props) {
+ const togglePalette = useToggleCommandPalette();
+ const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const showEncryptionSetup =
+ workspace != null &&
+ workspaceMeta != null &&
+ workspace.encryptionKeyChallenge != null &&
+ workspaceMeta.encryptionKey == null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showEncryptionSetup ? (
+
+ Enter Encryption Key
+
+ ) : (
+
+ )}
+
+ setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
+ }
+ />
+
+
+
+
+ );
+});
diff --git a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx
new file mode 100644
index 0000000..56d98a8
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx
@@ -0,0 +1,191 @@
+import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { useAuthTab } from "../hooks/useAuthTab";
+import { useHeadersTab } from "../hooks/useHeadersTab";
+import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+import { showDialog } from "../lib/dialog";
+import { router } from "../lib/router";
+import { CopyIconButton } from "./CopyIconButton";
+import { Button } from "./core/Button";
+import { CountBadge } from "./core/CountBadge";
+import { PlainInput } from "./core/PlainInput";
+import { SettingsList, SettingsSection } from "./core/SettingRow";
+import { TabContent, Tabs } from "./core/Tabs/Tabs";
+import { DnsOverridesEditor } from "./DnsOverridesEditor";
+import { HeadersEditor } from "./HeadersEditor";
+import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
+import { MarkdownEditor } from "./MarkdownEditor";
+import { ModelSettingsEditor } from "./ModelSettingsEditor";
+import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
+import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
+
+interface Props {
+ workspaceId: string;
+ hide: () => void;
+ tab?: WorkspaceSettingsTab;
+}
+
+const TAB_AUTH = "auth";
+const TAB_DNS = "dns";
+const TAB_HEADERS = "headers";
+const TAB_GENERAL = "general";
+const TAB_SETTINGS = "settings";
+
+export type WorkspaceSettingsTab =
+ | typeof TAB_AUTH
+ | typeof TAB_DNS
+ | typeof TAB_HEADERS
+ | typeof TAB_GENERAL
+ | typeof TAB_SETTINGS;
+
+const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
+
+export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
+ const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
+ const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
+ const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
+ const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
+ const inheritedHeaders = useInheritedHeaders(workspace ?? null);
+
+ if (workspace == null) {
+ return (
+
+ Workspace not found
+
+ );
+ }
+
+ if (workspaceMeta == null)
+ return (
+
+ WorkspaceMeta not found for workspace
+
+ );
+
+ return (
+ 0 ? (
+
+ ) : null,
+ },
+ ]}
+ storageKey="workspace_settings_tabs"
+ >
+
+
+
+
+ patchModel(workspace, { headers })}
+ stateKey={`headers.${workspace.id}`}
+ />
+
+
+
+
+ patchModel(workspaceMeta, { settingSyncDir: filePath })}
+ />
+
+
+
+
+
+
+
+
+
+
patchModel(workspace, { name })}
+ />
+
+ patchModel(workspace, { description })}
+ heightMode="auto"
+ />
+
+
+ {
+ const didDelete = await deleteModelWithConfirm(workspace, {
+ confirmName: workspace.name,
+ });
+ if (didDelete) {
+ hide(); // Only hide if actually deleted workspace
+ await router.navigate({ to: "/" });
+ }
+ }}
+ color="danger"
+ variant="border"
+ size="xs"
+ >
+ Delete Workspace
+
+
+ {workspaceId}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
+ showDialog({
+ id: "workspace-settings",
+ size: "lg",
+ className: "h-[calc(100vh-5rem)] max-h-200!",
+ noPadding: true,
+ render: ({ hide }) => (
+
+ ),
+ });
+};
diff --git a/apps/yaak-client/components/core/Alert.tsx b/apps/yaak-client/components/core/Alert.tsx
new file mode 100644
index 0000000..8315e40
--- /dev/null
+++ b/apps/yaak-client/components/core/Alert.tsx
@@ -0,0 +1,21 @@
+import { HStack, VStack } from "@yaakapp-internal/ui";
+import type { ReactNode } from "react";
+import { Button } from "./Button";
+
+export interface AlertProps {
+ onHide: () => void;
+ body: ReactNode;
+}
+
+export function Alert({ onHide, body }: AlertProps) {
+ return (
+
+ {body}
+
+
+ Okay
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/AutoScroller.tsx b/apps/yaak-client/components/core/AutoScroller.tsx
new file mode 100644
index 0000000..7675b79
--- /dev/null
+++ b/apps/yaak-client/components/core/AutoScroller.tsx
@@ -0,0 +1,118 @@
+import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
+import type { ReactElement, ReactNode, UIEvent } from "react";
+import { useCallback, useLayoutEffect, useRef, useState } from "react";
+import { IconButton } from "./IconButton";
+
+interface Props {
+ data: T[];
+ render: (item: T, index: number) => ReactElement;
+ header?: ReactNode;
+ /** Make container focusable for keyboard navigation */
+ focusable?: boolean;
+ /** Callback to expose the virtualizer for keyboard navigation */
+ onVirtualizerReady?: (virtualizer: Virtualizer) => void;
+}
+
+export function AutoScroller({
+ data,
+ render,
+ header,
+ focusable = false,
+ onVirtualizerReady,
+}: Props) {
+ const containerRef = useRef(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+
+ // The virtualizer
+ const rowVirtualizer = useVirtualizer({
+ count: data.length,
+ getScrollElement: () => containerRef.current,
+ estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
+ });
+
+ // Expose virtualizer to parent for keyboard navigation
+ useLayoutEffect(() => {
+ onVirtualizerReady?.(rowVirtualizer);
+ }, [rowVirtualizer, onVirtualizerReady]);
+
+ // Scroll to new items
+ const handleScroll = useCallback(
+ (e: UIEvent) => {
+ const el = e.currentTarget;
+
+ // Set auto-scroll when container is scrolled
+ const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
+ const newAutoScroll = pixelsFromBottom <= 0;
+ if (newAutoScroll !== autoScroll) {
+ setAutoScroll(newAutoScroll);
+ }
+ },
+ [autoScroll],
+ );
+
+ // Scroll to bottom on count change
+ useLayoutEffect(() => {
+ if (!autoScroll) return;
+
+ void data.length; // Trigger refresh when length changes
+
+ const el = containerRef.current;
+ if (el == null) return;
+
+ el.scrollTop = el.scrollHeight;
+ }, [autoScroll, data.length]);
+
+ return (
+
+ {!autoScroll && (
+
+ setAutoScroll((v) => !v)}
+ />
+
+ )}
+ {header ??
}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => {
+ const item = data[virtualItem.index];
+ return (
+ item != null && (
+
+ {render(item, virtualItem.index)}
+
+ )
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/BulkPairEditor.test.ts b/apps/yaak-client/components/core/BulkPairEditor.test.ts
new file mode 100644
index 0000000..b059980
--- /dev/null
+++ b/apps/yaak-client/components/core/BulkPairEditor.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parseBulkPairLine } from "./BulkPairEditor";
+
+describe("parseBulkPairLine", () => {
+ test("parses colon-space pairs as name and value", () => {
+ expect(parseBulkPairLine("foo: bar")).toMatchObject({
+ enabled: true,
+ name: "foo",
+ value: "bar",
+ });
+ });
+
+ test("preserves colon-without-space lines as a name with an empty value", () => {
+ expect(parseBulkPairLine("foo:bar")).toMatchObject({
+ enabled: true,
+ name: "foo:bar",
+ value: "",
+ });
+ });
+
+ test("preserves malformed lines instead of dropping their contents", () => {
+ expect(parseBulkPairLine("not a pair")).toMatchObject({
+ enabled: true,
+ name: "not a pair",
+ value: "",
+ });
+ });
+
+ test("unescapes newlines in parsed values", () => {
+ expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
+ enabled: true,
+ name: "foo",
+ value: "bar\nbaz",
+ });
+ });
+});
diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/apps/yaak-client/components/core/BulkPairEditor.tsx
new file mode 100644
index 0000000..fe3b4bd
--- /dev/null
+++ b/apps/yaak-client/components/core/BulkPairEditor.tsx
@@ -0,0 +1,63 @@
+import { useCallback, useMemo } from "react";
+import { generateId } from "../../lib/generateId";
+import { Editor } from "./Editor/LazyEditor";
+import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
+
+type Props = PairEditorProps;
+
+export function BulkPairEditor({
+ pairs,
+ onChange,
+ namePlaceholder,
+ valuePlaceholder,
+ forceUpdateKey,
+ forcedEnvironmentId,
+ stateKey,
+}: Props) {
+ const pairsText = useMemo(() => {
+ return pairs
+ .filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
+ .map(formatBulkPairLine)
+ .join("\n");
+ }, [pairs]);
+
+ const handleChange = useCallback(
+ (text: string) => {
+ const pairs = text
+ .split("\n")
+ .filter((l: string) => l.trim())
+ .map(parseBulkPairLine);
+ onChange(pairs);
+ },
+ [onChange],
+ );
+
+ return (
+
+ );
+}
+
+export function formatBulkPairLine(pair: Pair) {
+ const value = pair.value.replaceAll("\n", "\\n");
+ return `${pair.name}: ${value}`;
+}
+
+export function parseBulkPairLine(line: string): PairWithId {
+ const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
+ return {
+ enabled: true,
+ name: (name ?? line).trim(),
+ value: (value ?? "").replaceAll("\\n", "\n").trim(),
+ id: generateId(),
+ };
+}
diff --git a/apps/yaak-client/components/core/Button.tsx b/apps/yaak-client/components/core/Button.tsx
new file mode 100644
index 0000000..ec389e3
--- /dev/null
+++ b/apps/yaak-client/components/core/Button.tsx
@@ -0,0 +1,34 @@
+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(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(null);
+ useImperativeHandle(
+ ref,
+ () => buttonRef.current,
+ );
+
+ useHotKey(
+ hotkeyAction ?? null,
+ () => {
+ buttonRef.current?.click();
+ },
+ { priority: hotkeyPriority, enable: !hotkeyLabelOnly },
+ );
+
+ return ;
+});
diff --git a/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx b/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
new file mode 100644
index 0000000..6ea1130
--- /dev/null
+++ b/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
@@ -0,0 +1,25 @@
+import { useState } from "react";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+
+export function ButtonInfiniteLoading({
+ onClick,
+ isLoading,
+ loadingChildren,
+ children,
+ ...props
+}: ButtonProps & { loadingChildren?: string }) {
+ const [localIsLoading, setLocalIsLoading] = useState(false);
+ return (
+ {
+ setLocalIsLoading(true);
+ onClick?.(e);
+ }}
+ {...props}
+ >
+ {localIsLoading ? (loadingChildren ?? children) : children}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Checkbox.tsx b/apps/yaak-client/components/core/Checkbox.tsx
new file mode 100644
index 0000000..92b1942
--- /dev/null
+++ b/apps/yaak-client/components/core/Checkbox.tsx
@@ -0,0 +1,73 @@
+import { HStack, Icon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { IconTooltip } from "./IconTooltip";
+
+export interface CheckboxProps {
+ checked: boolean | "indeterminate";
+ title: ReactNode;
+ onChange: (checked: boolean) => void;
+ className?: string;
+ disabled?: boolean;
+ inputWrapperClassName?: string;
+ hideLabel?: boolean;
+ fullWidth?: boolean;
+ help?: ReactNode;
+ size?: "sm" | "md";
+}
+
+export function Checkbox({
+ checked,
+ onChange,
+ className,
+ inputWrapperClassName,
+ disabled,
+ title,
+ hideLabel,
+ fullWidth,
+ help,
+ size = "sm",
+}: CheckboxProps) {
+ return (
+
+
+
{
+ onChange(checked === "indeterminate" ? true : !checked);
+ }}
+ />
+
+
+
+
+ {!hideLabel && (
+
+ {title}
+
+ )}
+ {help && }
+
+ );
+}
diff --git a/apps/yaak-client/components/core/CheckboxTree.tsx b/apps/yaak-client/components/core/CheckboxTree.tsx
new file mode 100644
index 0000000..d6d4423
--- /dev/null
+++ b/apps/yaak-client/components/core/CheckboxTree.tsx
@@ -0,0 +1,114 @@
+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 {
+ key: string;
+ data: T;
+ children: CheckboxTreeNode[];
+}
+
+interface Props {
+ node: CheckboxTreeNode;
+ depth?: number;
+ /** Return "hidden" to render row alignment space instead of a checkbox */
+ checked: (node: CheckboxTreeNode) => CheckboxProps["checked"] | "hidden";
+ onCheck: (node: CheckboxTreeNode, checked: boolean) => void;
+ checkboxTitle?: (node: CheckboxTreeNode) => string;
+ isCheckboxDisabled?: (node: CheckboxTreeNode) => boolean;
+ /** An irrelevant row is hidden unless one of its descendants is relevant */
+ isRelevant: (node: CheckboxTreeNode) => boolean;
+ /** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
+ isCollapsedByDefault?: (node: CheckboxTreeNode) => boolean;
+ renderRow: (node: CheckboxTreeNode) => ReactNode;
+ onSelectRow?: (node: CheckboxTreeNode) => void;
+ canSelectRow?: (node: CheckboxTreeNode) => boolean;
+ isRowSelected?: (node: CheckboxTreeNode) => boolean;
+}
+
+export function CheckboxTree(props: Props) {
+ const { node, depth = 0 } = props;
+ const [collapsed, setCollapsed] = useState(
+ () => 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 = (
+
+ {props.renderRow(node)}
+
+ );
+
+ return (
+ 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
+ )}
+ >
+
+ {selected && (
+
+ )}
+ {hasVisibleChildren ? (
+
setCollapsed((v) => !v)}
+ >
+
+
+ ) : (
+
+ )}
+ {checked === "hidden" ? (
+
+ ) : (
+
props.onCheck(node, checked)}
+ />
+ )}
+ {selectable ? (
+ props.onSelectRow?.(node)}
+ >
+ {rowContent}
+
+ ) : (
+ rowContent
+ )}
+
+
+ {!collapsed &&
+ node.children.map((child) => (
+
+ ))}
+
+ );
+}
+
+function hasRelevantNode(
+ node: CheckboxTreeNode,
+ isRelevant: (node: CheckboxTreeNode) => boolean,
+): boolean {
+ return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
+}
diff --git a/apps/yaak-client/components/core/ColorPicker.tsx b/apps/yaak-client/components/core/ColorPicker.tsx
new file mode 100644
index 0000000..6b2f217
--- /dev/null
+++ b/apps/yaak-client/components/core/ColorPicker.tsx
@@ -0,0 +1,117 @@
+import classNames from "classnames";
+import { useState } from "react";
+import { HexColorPicker } from "react-colorful";
+import { useRandomKey } from "../../hooks/useRandomKey";
+import { Icon } from "@yaakapp-internal/ui";
+import { PlainInput } from "./PlainInput";
+
+interface Props {
+ onChange: (value: string | null) => void;
+ color: string | null;
+ className?: string;
+}
+
+export function ColorPicker({ onChange, color, className }: Props) {
+ const [updateKey, regenerateKey] = useRandomKey();
+ return (
+
+
{
+ onChange(color);
+ regenerateKey(); // To force input to change
+ }}
+ />
+ color.match(/#[0-9a-fA-F]{6}$/) !== null}
+ />
+
+ );
+}
+
+const colors = [
+ null,
+ "danger",
+ "warning",
+ "notice",
+ "success",
+ "primary",
+ "info",
+ "secondary",
+ "custom",
+] as const;
+
+export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
+ const [updateKey, regenerateKey] = useRandomKey();
+ const [selectedColor, setSelectedColor] = useState(() => {
+ if (color == null) return null;
+ const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
+ return c ?? "custom";
+ });
+ return (
+
+
+ {colors.map((color) => (
+ {
+ setSelectedColor(color);
+ if (color == null) {
+ onChange(null);
+ } else if (color === "custom") {
+ onChange("#ffffff");
+ } else {
+ onChange(`var(--${color})`);
+ }
+ }}
+ className={classNames(
+ "flex items-center justify-center",
+ "w-8 h-8 rounded-full transition-all",
+ selectedColor === color && "scale-[1.15]",
+ selectedColor === color ? "opacity-100" : "opacity-60",
+ color === null && "border border-text-subtle",
+ color === "primary" && "bg-primary",
+ color === "secondary" && "bg-secondary",
+ color === "success" && "bg-success",
+ color === "notice" && "bg-notice",
+ color === "warning" && "bg-warning",
+ color === "danger" && "bg-danger",
+ color === "info" && "bg-info",
+ color === "custom" &&
+ "bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]",
+ )}
+ >
+ {color == null && }
+
+ ))}
+
+ {selectedColor === "custom" && (
+ <>
+
{
+ onChange(color);
+ regenerateKey(); // To force input to change
+ }}
+ />
+ color.match(/#[0-9a-fA-F]{6}$/) !== null}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Confirm.tsx b/apps/yaak-client/components/core/Confirm.tsx
new file mode 100644
index 0000000..dac8dc9
--- /dev/null
+++ b/apps/yaak-client/components/core/Confirm.tsx
@@ -0,0 +1,74 @@
+import type { Color } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import type { FormEvent } from "react";
+import { useState } from "react";
+import { CopyIconButton } from "../CopyIconButton";
+import { Button } from "./Button";
+import { PlainInput } from "./PlainInput";
+
+export interface ConfirmProps {
+ onHide: () => void;
+ onResult: (result: boolean) => void;
+ confirmText?: string;
+ requireTyping?: string;
+ color?: Color;
+}
+
+export function Confirm({
+ onHide,
+ onResult,
+ confirmText,
+ requireTyping,
+ color = "primary",
+}: ConfirmProps) {
+ const [confirm, setConfirm] = useState("");
+ const handleHide = () => {
+ onResult(false);
+ onHide();
+ };
+
+ const didConfirm = !requireTyping || confirm === requireTyping;
+
+ const handleSuccess = (e: FormEvent) => {
+ e.preventDefault();
+ if (didConfirm) {
+ onResult(true);
+ onHide();
+ }
+ };
+
+ return (
+
+ {requireTyping && (
+
+ }
+ label={
+ <>
+ Type {requireTyping} to confirm
+ >
+ }
+ />
+ )}
+
+
+ {confirmText ?? "Confirm"}
+
+
+ Cancel
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/CountBadge.tsx b/apps/yaak-client/components/core/CountBadge.tsx
new file mode 100644
index 0000000..5b2fad7
--- /dev/null
+++ b/apps/yaak-client/components/core/CountBadge.tsx
@@ -0,0 +1,48 @@
+import type { Color } from "@yaakapp-internal/plugins";
+import classNames from "classnames";
+
+interface Props {
+ count: number | true;
+ count2?: number | true;
+ className?: string;
+ color?: Color;
+ showZero?: boolean;
+}
+
+export function CountBadge({ count, count2, className, color, showZero }: Props) {
+ if (count === 0 && !showZero) return null;
+
+ return (
+
+ {count === true ? (
+
+ ) : (
+ count
+ )}
+ {count2 != null && (
+ <>
+ /
+ {count2 === true ? (
+
+ ) : (
+ count2
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/DetailsBanner.tsx b/apps/yaak-client/components/core/DetailsBanner.tsx
new file mode 100644
index 0000000..1cc49f7
--- /dev/null
+++ b/apps/yaak-client/components/core/DetailsBanner.tsx
@@ -0,0 +1,60 @@
+import classNames from "classnames";
+import { atom, useAtom } from "jotai";
+import type { HTMLAttributes, ReactNode } from "react";
+import { useMemo } from "react";
+import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
+import type { BannerProps } from "@yaakapp-internal/ui";
+import { Banner } from "@yaakapp-internal/ui";
+
+interface Props extends HTMLAttributes {
+ summary: ReactNode;
+ color?: BannerProps["color"];
+ defaultOpen?: boolean;
+ storageKey?: string;
+}
+
+export function DetailsBanner({
+ className,
+ color,
+ summary,
+ children,
+ defaultOpen,
+ storageKey,
+ ...extraProps
+}: Props) {
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
+ const openAtom = useMemo(
+ () =>
+ storageKey
+ ? atomWithKVStorage(["details_banner", storageKey], defaultOpen ?? false)
+ : atom(defaultOpen ?? false),
+ [storageKey],
+ );
+
+ const [isOpen, setIsOpen] = useAtom(openAtom);
+
+ const handleToggle = (e: React.SyntheticEvent) => {
+ if (storageKey) {
+ setIsOpen(e.currentTarget.open);
+ }
+ };
+
+ return (
+
+
+
+
+ {summary}
+
+ {children}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx
new file mode 100644
index 0000000..c7962a1
--- /dev/null
+++ b/apps/yaak-client/components/core/Dialog.tsx
@@ -0,0 +1,131 @@
+import type { DialogSize } from "@yaakapp-internal/plugins";
+import { Heading, Overlay } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import * as m from "motion/react-m";
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import { IconButton } from "./IconButton";
+
+export interface DialogProps {
+ children: ReactNode;
+ open: boolean;
+ onClose?: () => void;
+ /** Block dismissal from the backdrop, Escape key, and built-in close button. */
+ disableClose?: boolean;
+ title?: ReactNode;
+ description?: ReactNode;
+ className?: string;
+ size?: DialogSize;
+ /** Hide the built-in close button without changing backdrop or Escape behavior. */
+ hideX?: boolean;
+ noPadding?: boolean;
+ noScroll?: boolean;
+ vAlign?: "top" | "center";
+}
+
+export function Dialog({
+ children,
+ className,
+ size = "full",
+ open,
+ onClose,
+ disableClose,
+ title,
+ description,
+ hideX,
+ noPadding,
+ noScroll,
+ vAlign = "center",
+}: DialogProps) {
+ const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
+ const descriptionId = useMemo(
+ () => (description ? Math.random().toString(36).slice(2) : undefined),
+ [description],
+ );
+
+ return (
+
+ {
+ // NOTE: We handle Escape on the element itself so that it doesn't close multiple
+ // dialogs and can be intercepted by children if needed.
+ if (e.key === "Escape") {
+ if (!disableClose) onClose?.();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+
+ {title ? (
+
+ {title}
+
+ ) : (
+
+ )}
+
+ {description ? (
+
+ {description}
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+ {/*Put close at the end so that it's the last thing to be tabbed to*/}
+ {!disableClose && !hideX && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/DismissibleBanner.tsx b/apps/yaak-client/components/core/DismissibleBanner.tsx
new file mode 100644
index 0000000..f2d1871
--- /dev/null
+++ b/apps/yaak-client/components/core/DismissibleBanner.tsx
@@ -0,0 +1,111 @@
+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;
+ onShow?: () => void | Promise;
+ actions?: {
+ label: string;
+ onClick: () => void;
+ color?: Color;
+ variant?: ButtonProps["variant"];
+ }[];
+}) {
+ const {
+ isLoading,
+ set: setDismissed,
+ value: dismissed,
+ } = useKeyValue({
+ 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 (
+
+
+
+ {children}
+
+ {
+ stopParentClick(event);
+ setDismissed(true).catch(console.error);
+ Promise.resolve(onDismiss?.()).catch(console.error);
+ }}
+ title="Dismiss message"
+ >
+ Dismiss
+
+ {actions?.map((a) => (
+ {
+ stopParentClick(event);
+ a.onClick();
+ }}
+ title={a.label}
+ >
+ {a.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx
new file mode 100644
index 0000000..8b3b94e
--- /dev/null
+++ b/apps/yaak-client/components/core/Dropdown.tsx
@@ -0,0 +1,1069 @@
+import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { atom } from "jotai";
+import * as m from "motion/react-m";
+import type {
+ CSSProperties,
+ HTMLAttributes,
+ MouseEvent,
+ ReactElement,
+ FocusEvent as ReactFocusEvent,
+ KeyboardEvent as ReactKeyboardEvent,
+ ReactNode,
+ RefObject,
+ SetStateAction,
+} from "react";
+import {
+ Children,
+ cloneElement,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useKey, useWindowSize } from "react-use";
+import { useClickOutside } from "../../hooks/useClickOutside";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useHotKey } from "../../hooks/useHotKey";
+import { useStateWithDeps } from "../../hooks/useStateWithDeps";
+import { generateId } from "../../lib/generateId";
+import { getNodeText } from "../../lib/getNodeText";
+import { jotaiStore } from "../../lib/jotai";
+import { fireAndForget } from "../../lib/fireAndForget";
+import { ErrorBoundary } from "../ErrorBoundary";
+import { Button } from "./Button";
+import { Hotkey } from "./Hotkey";
+import { IconButton } from "./IconButton";
+import type { SeparatorAction } from "./Separator";
+import { Separator } from "./Separator";
+
+export type DropdownItemSeparator = {
+ type: "separator";
+ label?: ReactNode;
+ hidden?: boolean;
+ /** A control shown beside the label, eg. revealing the labelled file on disk. */
+ action?: SeparatorAction;
+};
+
+export type DropdownItemContent = {
+ type: "content";
+ label?: ReactNode;
+ hidden?: boolean;
+};
+
+export type DropdownItemDefault = {
+ type?: "default";
+ label: ReactNode;
+ hotKeyAction?: HotkeyAction;
+ hotKeyLabelOnly?: boolean;
+ color?: "default" | "primary" | "danger" | "info" | "warning" | "notice" | "success";
+ disabled?: boolean;
+ hidden?: boolean;
+ leftSlot?: ReactNode;
+ rightSlot?: ReactNode;
+ waitForOnSelect?: boolean;
+ keepOpenOnSelect?: boolean;
+ onSelect?: () => void | Promise;
+ submenu?: DropdownItem[];
+ /** If true, submenu opens on click instead of hover */
+ submenuOpenOnClick?: boolean;
+ /**
+ * How the submenu opens. "row" (default) opens it from the row itself (hover, or click
+ * with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
+ * a dedicated button on the right that opens the submenu.
+ */
+ submenuTrigger?: "row" | "button";
+ icon?: IconProps["icon"];
+};
+
+export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
+
+export interface DropdownProps {
+ children: ReactElement>;
+ items: DropdownItem[];
+ fullWidth?: boolean;
+ hotKeyAction?: HotkeyAction;
+ onOpen?: () => void;
+}
+
+export interface DropdownRef {
+ isOpen: boolean;
+ open: (index?: number) => void;
+ toggle: () => void;
+ close?: () => void;
+ next?: (incrBy?: number) => void;
+ prev?: (incrBy?: number) => void;
+ select?: () => void;
+}
+
+// Every dropdown gets a unique ID and we use this global atom to ensure
+// only one dropdown can be open at a time.
+// TODO: Also make ContextMenu use this
+const openAtom = atom(null);
+
+export const Dropdown = forwardRef(function Dropdown(
+ { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,
+ ref,
+) {
+ const id = useRef(generateId());
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ return jotaiStore.sub(openAtom, () => {
+ const globalOpenId = jotaiStore.get(openAtom);
+ const newIsOpen = globalOpenId === id.current;
+ if (newIsOpen !== isOpen) {
+ setIsOpen(newIsOpen);
+ }
+ });
+ }, [isOpen]);
+
+ // const [isOpen, _setIsOpen] = useState(false);
+ const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null);
+ const buttonRef = useRef(null);
+ const menuRef = useRef>(null);
+
+ const handleSetIsOpen = useCallback(
+ (o: SetStateAction) => {
+ jotaiStore.set(openAtom, (prevId) => {
+ const prevIsOpen = prevId === id.current;
+ const newIsOpen = typeof o === "function" ? o(prevIsOpen) : o;
+ // Persist background color of button until we close the dropdown
+ if (newIsOpen) {
+ onOpen?.();
+ if (buttonRef.current) {
+ buttonRef.current.style.backgroundColor = window
+ .getComputedStyle(buttonRef.current)
+ .getPropertyValue("background-color");
+ }
+ }
+ return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
+ });
+ },
+ [onOpen],
+ );
+
+ // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
+ // we have of detecting the dropdown closed, to do cleanup.
+ useEffect(() => {
+ if (!isOpen) {
+ // Clear persisted BG
+ if (buttonRef.current) buttonRef.current.style.backgroundColor = "";
+ // Set to different value when opened and closed to force it to update. This is to force
+ // to reset its selected-index state, which it does when this prop changes
+ setDefaultSelectedIndex(null);
+ }
+ }, [isOpen]);
+
+ // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
+ // the ref will not update when menuRef updates, causing stale callback state to be used.
+ const menuRefCurrent = menuRef.current;
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ ...menuRefCurrent,
+ isOpen: isOpen,
+ toggle() {
+ if (!isOpen) this.open();
+ else this.close();
+ },
+ open(index?: number) {
+ handleSetIsOpen(true);
+ setDefaultSelectedIndex(index ?? -1);
+ },
+ close() {
+ handleSetIsOpen(false);
+ },
+ }),
+ [isOpen, handleSetIsOpen, menuRefCurrent],
+ );
+
+ useHotKey(hotKeyAction ?? null, () => {
+ setDefaultSelectedIndex(0);
+ handleSetIsOpen(true);
+ });
+
+ const child = useMemo(() => {
+ const existingChild = Children.only(children);
+ const originalOnClick = existingChild.props?.onClick;
+ const props: HTMLAttributes & { ref: RefObject } =
+ {
+ ...existingChild.props,
+ ref: buttonRef,
+ "aria-haspopup": "true",
+ onClick: (e: MouseEvent) => {
+ // Call original onClick first if it exists
+ originalOnClick?.(e);
+
+ // Only toggle dropdown if event wasn't prevented
+ if (!e.defaultPrevented) {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSetIsOpen((o) => !o); // Toggle dropdown
+ }
+ },
+ };
+ return cloneElement(existingChild, props);
+ }, [children, handleSetIsOpen]);
+
+ useEffect(() => {
+ buttonRef.current?.setAttribute("aria-expanded", isOpen.toString());
+ }, [isOpen]);
+
+ const windowSize = useWindowSize();
+ const triggerRect = useMemo(() => {
+ if (!windowSize) return null; // No-op to TS happy with this dep
+ if (!isOpen) return null;
+ return buttonRef.current?.getBoundingClientRect();
+ }, [isOpen, windowSize]);
+
+ return (
+ <>
+ {child}
+
+ handleSetIsOpen(false)}
+ isOpen={isOpen}
+ />
+
+ >
+ );
+});
+
+export interface ContextMenuProps {
+ triggerPosition: { x: number; y: number } | null;
+ /**
+ * The box the menu belongs to, when the trigger has one.
+ *
+ * Placement aligns to the trigger's left or right edge depending on the room beside it, so a
+ * menu opened from an element wants its real rect. Without one the position is treated as a
+ * zero-width point, which is right for a menu opened at the cursor and wrong for one opened
+ * from a button.
+ */
+ triggerRect?: Pick;
+ /**
+ * The element the menu belongs to, so a click on it doesn't count as a click outside.
+ *
+ * Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
+ * then the trigger's own click opens it again, and pressing it looks like it does nothing.
+ */
+ triggerRef?: RefObject;
+ className?: string;
+ items: DropdownProps["items"];
+ onClose: () => void;
+}
+
+export const ContextMenu = forwardRef(function ContextMenu(
+ { triggerPosition, triggerRect, triggerRef, className, items, onClose },
+ ref,
+) {
+ const triggerShape = useMemo(
+ () =>
+ triggerRect ?? {
+ top: triggerPosition?.y ?? 0,
+ bottom: triggerPosition?.y ?? 0,
+ left: triggerPosition?.x ?? 0,
+ right: triggerPosition?.x ?? 0,
+ },
+ [triggerPosition, triggerRect],
+ );
+
+ if (triggerPosition == null) return null;
+
+ return (
+
+ );
+});
+
+interface MenuProps {
+ className?: string;
+ defaultSelectedIndex: number | null;
+ triggerShape: Pick | null;
+ onClose: () => void;
+ onCloseAll?: () => void;
+ showTriangle?: boolean;
+ fullWidth?: boolean;
+ isOpen: boolean;
+ items: DropdownItem[];
+ // Any element, not just a button: a menu can be opened from anything, and this is only ever
+ // used to ask whether a click landed on the trigger
+ triggerRef?: RefObject;
+ isSubmenu?: boolean;
+}
+
+const Menu = forwardRef, MenuProps>(
+ (
+ {
+ className,
+ isOpen,
+ items,
+ fullWidth,
+ onClose,
+ onCloseAll,
+ triggerShape,
+ defaultSelectedIndex,
+ showTriangle,
+ triggerRef,
+ isSubmenu,
+ }: MenuProps,
+ ref,
+ ) => {
+ const [selectedIndex, setSelectedIndex] = useStateWithDeps(
+ defaultSelectedIndex ?? -1,
+ [defaultSelectedIndex],
+ );
+
+ const [filter, setFilter] = useState("");
+
+ // Clear filter when menu opens
+ useEffect(() => {
+ if (isOpen) {
+ setFilter("");
+ }
+ }, [isOpen]);
+
+ const [activeSubmenu, setActiveSubmenu] = useState<{
+ item: DropdownItemDefault;
+ parent: HTMLButtonElement;
+ viaKeyboard?: boolean;
+ } | null>(null);
+
+ const mousePosition = useRef({ x: 0, y: 0 });
+ const submenuTimeoutRef = useRef(null);
+ const submenuRef = useRef(null);
+
+ // HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
+ // have access to the latest value.
+ const selectedIndexRef = useRef(selectedIndex);
+ useEffect(() => {
+ selectedIndexRef.current = selectedIndex;
+ }, [selectedIndex]);
+
+ const handleClose = useCallback(() => {
+ onClose();
+ setActiveSubmenu(null);
+ }, [onClose]);
+
+ // Close the entire menu hierarchy (used when selecting an item)
+ const handleCloseAll = useCallback(() => {
+ if (onCloseAll) {
+ onCloseAll();
+ } else {
+ handleClose();
+ }
+ }, [onCloseAll, handleClose]);
+
+ // Handle type-ahead filtering (only for the deepest open menu)
+ const handleMenuKeyDown = (e: ReactKeyboardEvent) => {
+ // Skip if this menu has a submenu open - let the submenu handle typing
+ if (activeSubmenu) return;
+
+ const isCharacter = e.key.length === 1;
+ const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
+ if (isCharacter && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f + e.key);
+ setSelectedIndex(0);
+ } else if (e.key === "Backspace" && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f.slice(0, -1));
+ }
+ };
+
+ useKey(
+ "Escape",
+ () => {
+ if (!isOpen) return;
+ if (activeSubmenu) setActiveSubmenu(null);
+ else if (filter !== "") setFilter("");
+ else handleClose();
+ },
+ {},
+ [isOpen, filter, setFilter, handleClose, activeSubmenu],
+ );
+
+ const handlePrev = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? 0) - incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex--;
+ } else if (nextIndex < 0) {
+ nextIndex = items.length - 1;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ const handleNext = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? -1) + incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex++;
+ } else if (nextIndex >= items.length) {
+ nextIndex = 0;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ // Ensure selection is on a valid item (not hidden/separator/content)
+ useEffect(() => {
+ const item = items[selectedIndex ?? -1];
+ if (item?.hidden || item?.type === "separator" || item?.type === "content") {
+ handleNext();
+ }
+ }, [selectedIndex, items, handleNext]);
+
+ useKey(
+ "ArrowUp",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handlePrev();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowDown",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handleNext();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowLeft",
+ (e) => {
+ if (!isOpen) return;
+ // Only handle if this menu doesn't have an open submenu
+ // (let the deepest submenu handle the key first)
+ if (activeSubmenu) return;
+ // If this is a submenu, ArrowLeft closes it and returns to parent
+ if (isSubmenu) {
+ e.preventDefault();
+ onClose();
+ }
+ },
+ {},
+ [isOpen, isSubmenu, activeSubmenu, onClose],
+ );
+
+ const handleSelect = useCallback(
+ async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
+ // Handle click-to-open submenu
+ if ("submenu" in item && item.submenu && item.submenuOpenOnClick && parentEl) {
+ setActiveSubmenu({ item, parent: parentEl });
+ return;
+ }
+
+ if (!("onSelect" in item) || !item.onSelect) return;
+ setSelectedIndex(null);
+
+ const promise = item.onSelect();
+ if (item.waitForOnSelect) {
+ try {
+ await promise;
+ } catch {
+ // Nothing
+ }
+ }
+
+ if (!item.keepOpenOnSelect) {
+ handleCloseAll();
+ } else if (isSubmenu) {
+ // Keep the parent menu open, but close this submenu — its items may no
+ // longer describe the row after the action (e.g. Pin → Unpin, Remove)
+ handleClose();
+ }
+ },
+ [handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
+ );
+
+ useImperativeHandle(ref, () => {
+ return {
+ close: handleClose,
+ prev: handlePrev,
+ next: handleNext,
+ select: async () => {
+ const item = items[selectedIndexRef.current ?? -1] ?? null;
+ if (!item) return;
+ await handleSelect(item);
+ },
+ };
+ }, [handleClose, handleNext, handlePrev, handleSelect, items]);
+
+ const styles = useMemo<{
+ container: CSSProperties;
+ menu: CSSProperties;
+ triangle: CSSProperties;
+ upsideDown: boolean;
+ }>(() => {
+ if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
+
+ if (isSubmenu) {
+ const parentRect = triggerShape;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const spaceRight = docRect.width - parentRect.right;
+ const spaceBelow = docRect.height - parentRect.top;
+ const spaceAbove = parentRect.bottom;
+ const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
+ // Estimate submenu height (items * ~28px + padding), flip if not enough space below
+ const estimatedHeight = items.length * 28 + 20;
+ const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
+
+ return {
+ upsideDown: openUpward,
+ container: {
+ top: openUpward ? undefined : parentRect.top,
+ bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
+ left: openLeft ? undefined : parentRect.right,
+ right: openLeft ? docRect.width - parentRect.left : undefined,
+ },
+ menu: {
+ maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
+ },
+ triangle: {}, // No triangle for submenus
+ };
+ }
+
+ const menuMarginY = 5;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const width = triggerShape.right - triggerShape.left;
+ const heightAbove = triggerShape.top;
+ const heightBelow = docRect.height - triggerShape.bottom;
+ const horizontalSpaceRemaining = docRect.width - triggerShape.left;
+ const top = triggerShape.bottom;
+ const onRight = horizontalSpaceRemaining < 300;
+ const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;
+ const triggerWidth = triggerShape.right - triggerShape.left;
+ return {
+ upsideDown,
+ container: {
+ top: !upsideDown ? top + menuMarginY : undefined,
+ bottom: upsideDown
+ ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
+ : undefined,
+ right: onRight ? docRect.width - triggerShape.right : undefined,
+ left: !onRight ? triggerShape.left : undefined,
+ minWidth: fullWidth ? triggerWidth : undefined,
+ maxWidth: "40rem",
+ },
+ triangle: {
+ width: "0.4rem",
+ height: "0.4rem",
+ ...(onRight
+ ? { right: width / 2, marginRight: "-0.2rem" }
+ : { left: width / 2, marginLeft: "-0.2rem" }),
+ ...(upsideDown
+ ? { bottom: "-0.2rem", rotate: "225deg" }
+ : { top: "-0.2rem", rotate: "45deg" }),
+ },
+ menu: {
+ maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
+ },
+ };
+ }, [fullWidth, items.length, triggerShape, isSubmenu]);
+
+ const filteredItems = useMemo(
+ () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
+ [items, filter],
+ );
+
+ const handleFocus = useCallback(
+ (i: DropdownItem) => {
+ const index = filteredItems.indexOf(i) ?? null;
+ setSelectedIndex(index);
+ },
+ [filteredItems, setSelectedIndex],
+ );
+
+ useKey(
+ "ArrowRight",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (item?.type !== "separator" && item?.type !== "content" && item?.submenu) {
+ e.preventDefault();
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex],
+ );
+
+ useKey(
+ "Enter",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (!item || item.type === "separator" || item.type === "content") return;
+ e.preventDefault();
+ if (item.submenu && item.submenuTrigger !== "button") {
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ } else if (item.onSelect) {
+ fireAndForget(handleSelect(item));
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
+ );
+
+ const handleItemHover = useCallback(
+ (item: DropdownItemDefault, parent: HTMLButtonElement) => {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+
+ if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
+ setActiveSubmenu({ item, parent });
+ } else if (activeSubmenu && activeSubmenu.item !== item) {
+ // Hovering the row that owns the open submenu must not dismiss it — the
+ // pointer travels across the row on its way to a button-triggered submenu
+ submenuTimeoutRef.current = window.setTimeout(() => {
+ const submenuEl = submenuRef.current;
+ if (!submenuEl || !activeSubmenu) {
+ setActiveSubmenu(null);
+ return;
+ }
+
+ const { parent } = activeSubmenu;
+ const parentRect = parent.getBoundingClientRect();
+ const submenuRect = submenuEl.getBoundingClientRect();
+ const mouse = mousePosition.current;
+
+ if (
+ mouse.x >= submenuRect.left &&
+ mouse.x <= submenuRect.right &&
+ mouse.y >= submenuRect.top &&
+ mouse.y <= submenuRect.bottom
+ ) {
+ return;
+ }
+
+ const tolerance = 5;
+ const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
+ const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
+ const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
+ const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
+
+ const inTriangle =
+ isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
+
+ if (!inTriangle) {
+ setActiveSubmenu(null);
+ }
+ }, 100);
+ }
+ },
+ [activeSubmenu],
+ );
+
+ const menuRef = useRef(null);
+ useClickOutside(menuRef, handleClose, triggerRef);
+
+ // Keep focus on menu container when filtering leaves no items
+ useEffect(() => {
+ if (filteredItems.length === 0 && filter && menuRef.current) {
+ menuRef.current.focus();
+ }
+ }, [filteredItems.length, filter]);
+
+ const submenuTriggerShape = useMemo(() => {
+ if (!activeSubmenu) return null;
+ const rect = activeSubmenu.parent.getBoundingClientRect();
+ return {
+ top: rect.top,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ };
+ }, [activeSubmenu]);
+
+ const handleMouseMove = (event: React.MouseEvent) => {
+ mousePosition.current = { x: event.clientX, y: event.clientY };
+ };
+
+ const menuContent = (
+ {
+ // Prevent showing any ancestor context menus
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ role="menu"
+ aria-orientation="vertical"
+ dir="ltr"
+ style={styles.container}
+ className={classNames(
+ className,
+ "x-theme-menu",
+ "outline-hidden my-1 pointer-events-auto z-40",
+ "fixed",
+ )}
+ >
+ {showTriangle && !isSubmenu && (
+
+ )}
+
+ {filter && (
+
+
+ {filter}
+
+ )}
+ {filteredItems.length === 0 && (
+ No matches
+ )}
+ {filteredItems.map((item, i) => {
+ if (item.hidden) {
+ return null;
+ }
+ if (item.type === "separator") {
+ return (
+
+ {item.label}
+
+ );
+ }
+ if (item.type === "content") {
+ return (
+ // oxlint-disable-next-line no-array-index-key -- index is fine
+
+ {item.label}
+
+ );
+ }
+ const isParentOfActiveSubmenu = activeSubmenu?.item === item;
+ return (
+ setActiveSubmenu({ item, parent: el })}
+ // oxlint-disable-next-line no-array-index-key -- It's fine
+ key={i}
+ item={item}
+ />
+ );
+ })}
+
+ {activeSubmenu && (
+ {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+ }}
+ >
+
setActiveSubmenu(null)}
+ onCloseAll={handleCloseAll}
+ triggerShape={submenuTriggerShape}
+ />
+
+ )}
+
+ );
+
+ // Hotkeys must be rendered even when menu is closed (so they work globally)
+ const hotKeyElements = items.map(
+ (item, i) =>
+ item.type !== "separator" &&
+ item.type !== "content" &&
+ !item.hotKeyLabelOnly &&
+ item.hotKeyAction && (
+
+ ),
+ );
+
+ if (!isOpen) {
+ return <>{hotKeyElements}>;
+ }
+
+ if (isSubmenu) {
+ return menuContent;
+ }
+
+ return (
+ <>
+ {hotKeyElements}
+
+ {menuContent}
+
+ >
+ );
+ },
+);
+
+interface MenuItemProps {
+ className?: string;
+ item: DropdownItemDefault;
+ onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise;
+ onFocus: (item: DropdownItemDefault) => void;
+ onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ focused: boolean;
+ isParentOfActiveSubmenu?: boolean;
+}
+
+function MenuItem({
+ className,
+ focused,
+ onFocus,
+ onHover,
+ item,
+ onSelect,
+ onOpenSubmenu,
+ isParentOfActiveSubmenu,
+ ...props
+}: MenuItemProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const handleClick = useCallback(async () => {
+ if (item.waitForOnSelect) setIsLoading(true);
+ await onSelect?.(item, buttonRef.current ?? undefined);
+ if (item.waitForOnSelect) setIsLoading(false);
+ }, [item, onSelect]);
+
+ const handleFocus = useCallback(
+ (e: ReactFocusEvent) => {
+ e.stopPropagation(); // Don't trigger focus on any parents
+ return onFocus?.(item);
+ },
+ [item, onFocus],
+ );
+
+ const buttonRef = useRef(null);
+ const initRef = useCallback(
+ (el: HTMLButtonElement | null) => {
+ buttonRef.current = el;
+ if (el === null) return;
+ if (focused) {
+ setTimeout(() => el.focus(), 0);
+ }
+ },
+ [focused],
+ );
+
+ const handleMouseEnter = (e: MouseEvent) => {
+ onHover(item, e.currentTarget);
+ e.currentTarget.focus();
+ };
+
+ const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
+
+ const rightSlot =
+ item.submenu && !hasButtonSubmenu ? (
+
+ ) : (
+ (item.rightSlot ?? )
+ );
+
+ const button = (
+ e.currentTarget.blur()}
+ disabled={item.disabled}
+ onFocus={handleFocus}
+ onClick={handleClick}
+ justify="start"
+ leftSlot={
+ (isLoading || item.leftSlot || item.icon) && (
+
+ {isLoading ? : item.icon ? : item.leftSlot}
+
+ )
+ }
+ rightSlot={rightSlot && {rightSlot}
}
+ innerClassName="text-left!"
+ color="custom"
+ className={classNames(
+ className,
+ "h-xs", // More compact
+ "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
+ "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
+ isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
+ hasButtonSubmenu && "pr-8",
+ item.color === "danger" && "text-danger!",
+ item.color === "primary" && "text-primary!",
+ item.color === "success" && "text-success!",
+ item.color === "warning" && "text-warning!",
+ item.color === "notice" && "text-notice!",
+ item.color === "info" && "text-info!",
+ )}
+ {...props}
+ >
+ {item.label}
+
+ );
+
+ if (!hasButtonSubmenu) {
+ return button;
+ }
+
+ // The submenu trigger overlays the row as a sibling (not a child) because the row is
+ // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
+ // row keeps its focus highlight while the mouse is over the trigger.
+ return (
+ {
+ const el = buttonRef.current;
+ if (el == null) return;
+ onHover(item, el);
+ el.focus();
+ }}
+ onMouseLeave={() => buttonRef.current?.blur()}
+ >
+ {button}
+
+ {
+ // Prevent the trigger from stealing focus, which would unhighlight the row
+ e.preventDefault();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onOpenSubmenu(item, e.currentTarget);
+ }}
+ />
+
+
+ );
+}
+
+interface MenuItemHotKeyProps {
+ action: HotkeyAction | undefined;
+ onSelect: MenuItemProps["onSelect"];
+ item: MenuItemProps["item"];
+}
+
+function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
+ useHotKey(action ?? null, () => onSelect(item));
+ return null;
+}
+
+function sign(
+ p1: { x: number; y: number },
+ p2: { x: number; y: number },
+ p3: { x: number; y: number },
+) {
+ return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
+}
+
+function isPointInTriangle(
+ pt: { x: number; y: number },
+ v1: { x: number; y: number },
+ v2: { x: number; y: number },
+ v3: { x: number; y: number },
+) {
+ const d1 = sign(pt, v1, v2);
+ const d2 = sign(pt, v2, v3);
+ const d3 = sign(pt, v3, v1);
+
+ const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+ const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+
+ return !(has_neg && has_pos);
+}
diff --git a/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts b/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts
new file mode 100644
index 0000000..ff9ac07
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts
@@ -0,0 +1,13 @@
+import { type DecorationSet, MatchDecorator, type ViewUpdate } from "@codemirror/view";
+
+/**
+ * This is a custom MatchDecorator that will not decorate a match if the selection is inside it
+ */
+export class BetterMatchDecorator extends MatchDecorator {
+ updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
+ if (!update.startState.selection.eq(update.state.selection)) {
+ return super.createDeco(update.view);
+ }
+ return super.updateDeco(update, deco);
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.css b/apps/yaak-client/components/core/Editor/DiffViewer.css
new file mode 100644
index 0000000..6e48545
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/DiffViewer.css
@@ -0,0 +1,41 @@
+@reference "../../../main.css";
+
+.cm-wrapper.cm-multiline .cm-mergeView {
+ @apply h-full w-full overflow-auto pr-0.5;
+
+ .cm-mergeViewEditors {
+ @apply w-full min-h-full;
+ }
+
+ .cm-mergeViewEditor {
+ @apply w-full min-h-full relative;
+
+ .cm-collapsedLines {
+ @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
+ }
+ }
+
+ .cm-line {
+ @apply pl-1.5;
+ }
+ .cm-changedLine {
+ /* Round top corners only if previous line is not a changed line */
+ &:not(.cm-changedLine + &) {
+ @apply rounded-t-sm;
+ }
+ /* Round bottom corners only if next line is not a changed line */
+ &:not(:has(+ .cm-changedLine)) {
+ @apply rounded-b-sm;
+ }
+ }
+
+ /* Let content grow and disable individual scrolling for sync */
+ .cm-editor {
+ @apply h-auto! relative!;
+ position: relative !important;
+ }
+
+ .cm-scroller {
+ @apply overflow-visible!;
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.tsx b/apps/yaak-client/components/core/Editor/DiffViewer.tsx
new file mode 100644
index 0000000..f36f28e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/DiffViewer.tsx
@@ -0,0 +1,64 @@
+import { yaml } from "@codemirror/lang-yaml";
+import { syntaxHighlighting } from "@codemirror/language";
+import { MergeView } from "@codemirror/merge";
+import { EditorView } from "@codemirror/view";
+import classNames from "classnames";
+import { useEffect, useRef } from "react";
+import "./DiffViewer.css";
+import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
+
+interface Props {
+ /** Original/previous version (left side) */
+ original: string;
+ /** Modified/current version (right side) */
+ modified: string;
+ className?: string;
+}
+
+export function DiffViewer({ original, modified, className }: Props) {
+ const containerRef = useRef(null);
+ const viewRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ // Clean up previous instance
+ viewRef.current?.destroy();
+
+ const sharedExtensions = [
+ yaml(),
+ syntaxHighlighting(syntaxHighlightStyle),
+ ...readonlyExtensions,
+ EditorView.lineWrapping,
+ ];
+
+ viewRef.current = new MergeView({
+ a: {
+ doc: original,
+ extensions: sharedExtensions,
+ },
+ b: {
+ doc: modified,
+ extensions: sharedExtensions,
+ },
+ parent: containerRef.current,
+ collapseUnchanged: { margin: 2, minSize: 3 },
+ highlightChanges: false,
+ gutter: true,
+ orientation: "a-b",
+ revertControls: undefined,
+ });
+
+ return () => {
+ viewRef.current?.destroy();
+ viewRef.current = null;
+ };
+ }, [original, modified]);
+
+ return (
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/apps/yaak-client/components/core/Editor/Editor.css
new file mode 100644
index 0000000..bc473ab
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/Editor.css
@@ -0,0 +1,515 @@
+@reference "../../../main.css";
+
+.cm-wrapper {
+ @apply h-full overflow-hidden;
+
+ .cm-editor {
+ @apply w-full block text-base;
+
+ /* Regular cursor */
+
+ .cm-cursor {
+ @apply border-text!;
+ /* Widen the cursor a bit */
+ @apply border-l-[2px];
+ }
+
+ /* Vim-mode cursor */
+
+ .cm-fat-cursor {
+ @apply outline-0! bg-text!;
+ @apply text-surface!;
+ }
+
+ /* Matching bracket */
+
+ .cm-matchingBracket {
+ @apply bg-transparent border-b border-b-text-subtle;
+ }
+
+ &:not(.cm-focused) {
+ .cm-cursor,
+ .cm-fat-cursor {
+ @apply hidden;
+ }
+ }
+
+ &.cm-focused {
+ outline: none !important;
+ }
+
+ .cm-content {
+ @apply py-0;
+ }
+
+ .cm-line {
+ @apply w-full;
+ /* Important! Ensure it spans the entire width */
+ @apply w-full text-text px-0;
+
+ /* So the search highlight border is not cut off by editor view */
+ @apply pl-[1px];
+ }
+
+ .cm-placeholder {
+ @apply text-placeholder;
+ }
+
+ .cm-scroller {
+ /* Inherit line-height from outside */
+ line-height: inherit;
+
+ * {
+ @apply cursor-text;
+ @apply caret-transparent!;
+ }
+ }
+
+ .cm-selectionBackground {
+ @apply bg-selection!;
+ }
+
+ /* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
+ residual line below wrapped lines after deselecting (CodeMirror issue #1600, #1627).
+ The layer div must be hidden when empty to force a repaint. */
+ .cm-selectionLayer:empty {
+ display: none;
+ }
+
+ /* Style gutters */
+
+ .cm-gutters {
+ @apply border-0 text-text-subtlest bg-surface pr-1.5;
+ /* Not sure why, but there's a tiny gap left of the gutter that you can see text
+ through. Move left slightly to fix that. */
+ @apply -left-[1px];
+
+ .cm-gutterElement {
+ @apply cursor-default;
+ }
+ }
+
+ .cm-gutter-lint {
+ @apply w-auto!;
+
+ .cm-gutterElement {
+ @apply px-0;
+ }
+
+ .cm-lint-marker {
+ @apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
+ @apply rounded-full w-[0.9em] h-[0.9em];
+
+ content: "";
+
+ &.cm-lint-marker-error {
+ @apply bg-danger;
+ }
+ }
+ }
+
+ .template-tag {
+ /* Colors */
+ @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
+ @apply hover:border-border hover:text-text hover:bg-surface-highlight;
+
+ @apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
+
+ -webkit-text-security: none;
+
+ * {
+ @apply cursor-default;
+ }
+
+ /* The chevron on a tag that opens a menu. The tag itself is the button, so this is
+ only the affordance that says so. */
+ .tag-action {
+ @apply inline-flex items-center align-middle ml-1;
+ @apply text-text-subtlest;
+
+ svg {
+ @apply w-[0.9em] h-[0.9em];
+ }
+ }
+
+ /* A preview of the value a tag stands for. Sized before the image loads, so arriving
+ never changes the line's height. */
+ .tag-thumbnail {
+ @apply inline-block align-middle mr-1 rounded-xs;
+ @apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
+ }
+
+ .fn {
+ @apply inline-block;
+ .fn-inner {
+ @apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
+ }
+ .fn-arg-name {
+ /* Nothing yet */
+ @apply opacity-60;
+ }
+ .fn-arg-value {
+ @apply inline-block truncate;
+ }
+ .fn-bracket {
+ @apply text-text-subtle opacity-30;
+ }
+ }
+ }
+
+ .hyperlink-widget {
+ & > * {
+ @apply underline;
+ }
+
+ &:hover > * {
+ @apply text-primary;
+ }
+
+ -webkit-text-security: none;
+ }
+ }
+
+ &.cm-singleline {
+ .cm-editor {
+ @apply w-full h-full;
+ }
+
+ .cm-scroller {
+ @apply font-mono text-xs;
+
+ /* Hide scrollbars */
+
+ &::-webkit-scrollbar-corner,
+ &::-webkit-scrollbar {
+ @apply hidden!;
+ }
+ }
+ }
+
+ &.cm-multiline {
+ &.cm-full-height {
+ @apply relative;
+
+ .cm-editor {
+ @apply inset-0 absolute;
+ position: absolute !important;
+ }
+ }
+
+ .cm-editor {
+ @apply h-full;
+ }
+
+ .cm-scroller {
+ @apply font-mono text-editor;
+ }
+ }
+}
+
+/* Style search matches */
+.cm-searchMatch {
+ @apply bg-transparent!;
+ @apply rounded-[2px] outline outline-1;
+
+ &.cm-searchMatch-selected {
+ @apply outline-text;
+ @apply bg-text!;
+
+ &,
+ * {
+ @apply text-surface! font-semibold!;
+ }
+ }
+}
+
+/* Obscure text for password fields */
+.cm-wrapper.cm-obscure-text .cm-line {
+ -webkit-text-security: disc;
+}
+
+/* Obscure text for password fields */
+.cm-wrapper.cm-obscure-text .cm-line {
+ -webkit-text-security: disc;
+
+ .cm-placeholder {
+ -webkit-text-security: none;
+ }
+}
+
+.cm-editor .cm-gutterElement {
+ @apply flex items-center;
+ transition: color var(--transition-duration);
+}
+
+.cm-editor .fold-gutter-icon {
+ @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
+ @apply cursor-default!;
+}
+
+.cm-editor .fold-gutter-icon::after {
+ @apply block w-1.5 h-1.5 p-0.5 border-transparent border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
+}
+
+/* Rotate the fold gutter chevron when open */
+.cm-editor .fold-gutter-icon[data-open]::after {
+ @apply rotate-[-45deg];
+}
+
+/* Adjust fold gutter icon position after rotation */
+.cm-editor .fold-gutter-icon:not([data-open])::after {
+ @apply relative -left-[0.1em] top-[0.1em] rotate-[-135deg];
+}
+
+.cm-editor .fold-gutter-icon:hover {
+ @apply text-text bg-surface-highlight;
+}
+
+.cm-editor .cm-foldPlaceholder {
+ @apply px-2 border border-border-subtle bg-surface-highlight;
+ @apply hover:text-text hover:border-border-subtle text-text;
+ @apply cursor-default!;
+}
+
+.cm-editor .cm-activeLineGutter {
+ @apply bg-transparent text-text-subtle;
+}
+
+/* Cursor and mouse cursor for readonly mode */
+.cm-wrapper.cm-readonly {
+ &.cm-singleline * {
+ @apply cursor-default;
+ }
+}
+
+.cm-singleline .cm-editor {
+ .cm-content {
+ @apply h-full flex items-center;
+
+ /* Break characters on line wrapping mode, useful for URL field.
+ * We can make this dynamic if we need it to be configurable later
+ */
+
+ &.cm-lineWrapping {
+ @apply break-all;
+ }
+ }
+}
+
+.cm-tooltip-lint {
+ @apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
+
+ .cm-diagnostic-error {
+ @apply border-l-danger px-4 py-2;
+ }
+}
+
+.cm-lintPoint {
+ &.cm-lintPoint-error {
+ &::after {
+ @apply border-b-danger;
+ }
+ }
+}
+
+.cm-tooltip.cm-tooltip-hover {
+ @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
+ @apply p-1.5;
+
+ /* Style the tooltip for popping up "open in browser" and other stuff */
+
+ a,
+ button {
+ @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
+ }
+
+ a {
+ @apply cursor-default!;
+
+ &::after {
+ @apply text-text bg-text h-3 w-3 ml-1;
+ content: "";
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
+ -webkit-mask-size: contain;
+ display: inline-block;
+ }
+ }
+}
+
+/* NOTE: Extra selector required to override default styles */
+.cm-tooltip.cm-tooltip-autocomplete,
+.cm-tooltip.cm-completionInfo {
+ @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
+
+ & * {
+ @apply font-mono! text-editor!;
+ }
+
+ .cm-completionIcon {
+ @apply opacity-80 italic;
+
+ &::after {
+ content: "a" !important; /* Default (eg. for GraphQL) */
+ }
+
+ &.cm-completionIcon-function::after {
+ content: "f" !important;
+ @apply text-info;
+ }
+
+ &.cm-completionIcon-variable::after {
+ content: "x" !important;
+ @apply text-primary;
+ }
+
+ &.cm-completionIcon-namespace::after {
+ content: "n" !important;
+ @apply text-warning;
+ }
+
+ &.cm-completionIcon-constant::after {
+ content: "c" !important;
+ @apply text-notice;
+ }
+
+ &.cm-completionIcon-class::after {
+ content: "o" !important;
+ }
+
+ &.cm-completionIcon-enum::after {
+ content: "e" !important;
+ }
+
+ &.cm-completionIcon-interface::after {
+ content: "i" !important;
+ }
+
+ &.cm-completionIcon-keyword::after {
+ content: "k" !important;
+ }
+
+ &.cm-completionIcon-method::after {
+ content: "m" !important;
+ }
+
+ &.cm-completionIcon-property::after {
+ content: "a" !important;
+ }
+
+ &.cm-completionIcon-text::after {
+ content: "t" !important;
+ }
+
+ &.cm-completionIcon-type::after {
+ content: "t" !important;
+ }
+ }
+
+ &.cm-completionInfo {
+ @apply mx-0.5 -mt-0.5 font-sans;
+ }
+
+ * {
+ @apply transition-none;
+ }
+
+ &.cm-tooltip-autocomplete {
+ @apply font-mono;
+
+ & > ul {
+ @apply p-1 max-h-[40vh];
+ }
+
+ & > ul > li {
+ @apply cursor-default px-2 h-[2em] rounded-sm text-text flex items-center;
+ }
+
+ & > ul > li[aria-selected] {
+ @apply bg-surface-highlight text-text;
+ }
+
+ .cm-completionIcon {
+ @apply text-sm flex items-center pb-0.5 shrink-0;
+ }
+
+ .cm-completionLabel {
+ @apply text-text;
+ }
+
+ .cm-completionDetail {
+ @apply ml-auto pl-6 text-text-subtle;
+ }
+ }
+}
+
+.cm-editor .cm-panels {
+ @apply bg-surface-highlight backdrop-blur-sm p-1 mb-1 text-text z-20 rounded-md;
+
+ input,
+ button {
+ @apply rounded-sm outline-hidden;
+ }
+
+ button {
+ @apply border-border-subtle bg-surface-highlight text-text hover:border-info;
+ @apply appearance-none bg-none cursor-default;
+ }
+
+ button[name="close"] {
+ @apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
+ }
+
+ input {
+ @apply bg-surface border-border-subtle focus:border-border-focus;
+ @apply border outline-hidden;
+ }
+
+ input.cm-textfield {
+ @apply cursor-text;
+ }
+
+ .cm-search label {
+ @apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;
+
+ input[type="checkbox"] {
+ @apply hidden;
+ }
+
+ &:has(:checked) {
+ @apply text-primary border-border;
+ }
+ }
+
+ /* Hide the "All" button */
+
+ button[name="select"] {
+ @apply hidden;
+ }
+
+ /* Replace next/prev button text with chevron icons */
+
+ .cm-search button[name="next"],
+ .cm-search button[name="prev"] {
+ @apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;
+ }
+
+ .cm-search button[name="prev"]::after,
+ .cm-search button[name="next"]::after {
+ @apply block w-3.5 h-3.5 bg-text;
+ content: "";
+ }
+
+ .cm-search button[name="prev"]::after {
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+ }
+
+ .cm-search button[name="next"]::after {
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
+ }
+
+ .cm-search-match-count {
+ @apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx
new file mode 100644
index 0000000..fa18b8e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/Editor.tsx
@@ -0,0 +1,770 @@
+import { startCompletion } from "@codemirror/autocomplete";
+import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
+import { foldState, forceParsing } from "@codemirror/language";
+import type { EditorStateConfig, Extension } from "@codemirror/state";
+import { Compartment, EditorState } from "@codemirror/state";
+import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
+import { emacs } from "@replit/codemirror-emacs";
+import { vim } from "@replit/codemirror-vim";
+
+import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
+import { debounce } from "@yaakapp-internal/lib";
+import type { EditorKeymap } from "@yaakapp-internal/models";
+import { settingsAtom } from "@yaakapp-internal/models";
+import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { GraphQLSchema } from "graphql";
+import { useAtomValue } from "jotai";
+import type { ReactNode, RefObject } from "react";
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ 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 { docFingerprint } from "../../../lib/docFingerprint";
+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 { IconButton } from "../IconButton";
+import "./Editor.css";
+import {
+ baseExtensions,
+ editableExtensions,
+ getLanguageExtension,
+ multiLineExtensions,
+ readonlyExtensions,
+} from "./extensions";
+import type { GenericCompletionConfig } from "./genericCompletion";
+import { singleLineExtensions } from "./singleLine";
+
+// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
+const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
+
+const keymapExtensions: Record = {
+ vim: vim(),
+ emacs: emacs(),
+ vscode: keymap.of(vsCodeWithoutTab),
+ default: [],
+};
+
+export interface EditorProps {
+ actions?: ReactNode;
+ autoFocus?: boolean;
+ autoSelect?: boolean;
+ autocomplete?: GenericCompletionConfig;
+ autocompleteFunctions?: boolean;
+ autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
+ className?: string;
+ defaultValue?: string | null;
+ disableTabIndent?: boolean;
+ disabled?: boolean;
+ extraExtensions?: Extension[] | Extension;
+ forcedEnvironmentId?: string;
+ forceUpdateKey?: string | number;
+ format?: (v: string) => Promise;
+ heightMode?: "auto" | "full";
+ hideGutter?: boolean;
+ id?: string;
+ language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
+ lintExtension?: Extension;
+ graphQLSchema?: GraphQLSchema | null;
+ onBlur?: () => void;
+ onChange?: (value: string) => void;
+ onFocus?: () => void;
+ onKeyDown?: (e: KeyboardEvent) => void;
+ onPaste?: (value: string) => void;
+ onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
+ placeholder?: string;
+ readOnly?: boolean;
+ singleLine?: boolean;
+ containerOnly?: boolean;
+ stateKey: string | null;
+ tooltipContainer?: HTMLElement;
+ type?: "text" | "password";
+ wrapLines?: boolean;
+ setRef?: (view: EditorView | null) => void;
+}
+
+const stateFields = { history: historyField, folds: foldState };
+
+const emptyVariables: WrappedEnvironmentVariable[] = [];
+const emptyExtension: Extension = [];
+
+export function Editor(props: EditorProps) {
+ return ;
+}
+
+function EditorInner({
+ actions,
+ autoFocus,
+ autoSelect,
+ autocomplete,
+ autocompleteFunctions,
+ autocompleteVariables,
+ className,
+ defaultValue,
+ disableTabIndent,
+ disabled,
+ extraExtensions,
+ forcedEnvironmentId,
+ forceUpdateKey,
+ format,
+ heightMode,
+ hideGutter,
+ graphQLSchema,
+ language,
+ lintExtension,
+ onBlur,
+ onChange,
+ onFocus,
+ onKeyDown,
+ onPaste,
+ onPasteOverwrite,
+ placeholder,
+ readOnly,
+ singleLine,
+ containerOnly,
+ stateKey,
+ type,
+ wrapLines,
+ setRef,
+}: 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 (disabled) {
+ readOnly = true;
+ }
+
+ if (
+ singleLine ||
+ language == null ||
+ language === "text" ||
+ language === "url" ||
+ language === "pairs"
+ ) {
+ disableTabIndent = true;
+ }
+
+ if (format == null && !readOnly) {
+ format =
+ language === "json"
+ ? tryFormatJson
+ : language === "xml" || language === "html"
+ ? tryFormatXml
+ : undefined;
+ }
+
+ const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handleChange = useRef(onChange);
+ useEffect(() => {
+ handleChange.current = onChange;
+ }, [onChange]);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handlePaste = useRef(onPaste);
+ useEffect(() => {
+ handlePaste.current = onPaste;
+ }, [onPaste]);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handlePasteOverwrite = useRef(onPasteOverwrite);
+ useEffect(() => {
+ handlePasteOverwrite.current = onPasteOverwrite;
+ }, [onPasteOverwrite]);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handleFocus = useRef(onFocus);
+ useEffect(() => {
+ handleFocus.current = onFocus;
+ }, [onFocus]);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handleBlur = useRef(onBlur);
+ useEffect(() => {
+ handleBlur.current = onBlur;
+ }, [onBlur]);
+
+ // Use ref so we can update the handler without re-initializing the editor
+ const handleKeyDown = useRef(onKeyDown);
+ useEffect(() => {
+ handleKeyDown.current = onKeyDown;
+ }, [onKeyDown]);
+
+ // Update placeholder
+ const placeholderCompartment = useRef(new Compartment());
+ useEffect(
+ function configurePlaceholder() {
+ if (cm.current === null) return;
+ const ext = placeholderExt(placeholderElFromText(placeholder));
+ const effects = placeholderCompartment.current.reconfigure(ext);
+ cm.current?.view.dispatch({ effects });
+ },
+ [placeholder],
+ );
+
+ // Update vim
+ const keymapCompartment = useRef(new Compartment());
+ useEffect(
+ function configureKeymap() {
+ if (cm.current === null) return;
+ const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
+ // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
+ if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
+ if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
+ if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
+ if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
+
+ const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
+ const effects = keymapCompartment.current.reconfigure(ext);
+ cm.current.view.dispatch({ effects });
+ },
+ [settings.editorKeymap],
+ );
+
+ // Update wrap lines
+ const wrapLinesCompartment = useRef(new Compartment());
+ useEffect(
+ function configureWrapLines() {
+ if (cm.current === null) return;
+ const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
+ // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
+ if (wrapLines && current !== emptyExtension) return; // Nothing to do
+ if (!wrapLines && current === emptyExtension) return; // Nothing to do
+
+ const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
+ const effects = wrapLinesCompartment.current.reconfigure(ext);
+ cm.current?.view.dispatch({ effects });
+ },
+ [wrapLines],
+ );
+
+ // Update tab indent
+ const tabIndentCompartment = useRef(new Compartment());
+ useEffect(
+ function configureTabIndent() {
+ if (cm.current === null) return;
+ const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
+ // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
+ if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
+ if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
+
+ const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
+ const effects = tabIndentCompartment.current.reconfigure(ext);
+ cm.current?.view.dispatch({ effects });
+ },
+ [disableTabIndent],
+ );
+
+ // Update read-only
+ const readOnlyCompartment = useRef(new Compartment());
+ useEffect(
+ function configureReadOnly() {
+ if (cm.current === null) return;
+ const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
+ const next = readOnly ? readonlyExtensions : editableExtensions;
+ // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
+ if (current === next) return;
+
+ const effects = readOnlyCompartment.current.reconfigure(next);
+ cm.current?.view.dispatch({ effects });
+ },
+ [readOnly],
+ );
+
+ const onClickFunction = useCallback(
+ async (fn: TemplateFunction, tagValue: string, startPos: number) => {
+ const show = () => {
+ if (cm.current === null) return;
+ TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
+ };
+
+ 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,
+ );
+
+ // Update the language extension when the language changes
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
+ useEffect(() => {
+ if (cm.current === null) return;
+ const { view, languageCompartment } = cm.current;
+ const ext = getLanguageExtension({
+ useTemplating,
+ language,
+ lintExtension,
+ hideGutter,
+ environmentVariables,
+ autocomplete,
+ completionOptions,
+ onClickVariable,
+ onClickMissingVariable,
+ onClickPathParameter,
+ graphQLSchema: graphQLSchema ?? null,
+ });
+ view.dispatch({ effects: languageCompartment.reconfigure(ext) });
+ }, [
+ language,
+ lintExtension,
+ autocomplete,
+ environmentVariables,
+ onClickFunction,
+ onClickVariable,
+ onClickMissingVariable,
+ onClickPathParameter,
+ completionOptions,
+ useTemplating,
+ graphQLSchema,
+ hideGutter,
+ ]);
+
+ // Initialize the editor when ref mounts
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary
+ const initEditorRef = useCallback(
+ function initEditorRef(container: HTMLDivElement | null) {
+ if (container === null) {
+ flushCachedEditorState(stateKey);
+ cm.current?.view.destroy();
+ cm.current = null;
+ return;
+ }
+
+ try {
+ const languageCompartment = new Compartment();
+ const langExt = getLanguageExtension({
+ useTemplating,
+ language,
+ lintExtension,
+ completionOptions,
+ autocomplete,
+ environmentVariables,
+ onClickVariable,
+ onClickMissingVariable,
+ onClickPathParameter,
+ graphQLSchema: graphQLSchema ?? null,
+ });
+ const extensions = [
+ languageCompartment.of(langExt),
+ placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
+ wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
+ tabIndentCompartment.current.of(
+ !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
+ ),
+ keymapCompartment.current.of(
+ keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
+ ),
+ readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
+ ...getExtensions({
+ container,
+ singleLine,
+ hideGutter,
+ stateKey,
+ onChange: handleChange,
+ onPaste: handlePaste,
+ onPasteOverwrite: handlePasteOverwrite,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ onKeyDown: handleKeyDown,
+ }),
+ ...(Array.isArray(extraExtensions)
+ ? extraExtensions
+ : extraExtensions
+ ? [extraExtensions]
+ : []),
+ ];
+
+ const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
+
+ const doc = `${defaultValue ?? ""}`;
+ const config: EditorStateConfig = { extensions, doc };
+
+ const state = cachedJsonState
+ ? EditorState.fromJSON(cachedJsonState, config, stateFields)
+ : EditorState.create(config);
+
+ const view = new EditorView({ state, parent: container });
+
+ // For large documents, the parser may parse the max number of lines and fail to add
+ // things like fold markers because of it.
+ // This forces it to parse more but keeps the timeout to the default of 100 ms.
+ forceParsing(view, 9e6, 100);
+
+ cm.current = { view, languageCompartment };
+ if (autoFocus) {
+ view.focus();
+ }
+ if (autoSelect) {
+ view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
+ }
+ setRef?.(view);
+ } catch (e) {
+ console.log("Failed to initialize Codemirror", e);
+ }
+ },
+ [forceUpdateKey],
+ );
+
+ // For read-only mode, update content when `defaultValue` changes
+ useEffect(
+ function updateReadOnlyEditor() {
+ if (readOnly && cm.current?.view != null) {
+ updateContents(cm.current.view, defaultValue || "");
+ }
+ },
+ [defaultValue, readOnly],
+ );
+
+ // Force input to update when receiving change and not in focus
+ useLayoutEffect(
+ function updateNonFocusedEditor() {
+ const notFocused = !cm.current?.view.hasFocus;
+ if (notFocused && cm.current != null) {
+ updateContents(cm.current.view, defaultValue || "");
+ }
+ },
+ [defaultValue],
+ );
+
+ // Add bg classes to actions, so they appear over the text
+ const decoratedActions = useMemo(() => {
+ const results = [];
+ const actionClassName = classNames(
+ "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
+ );
+
+ if (format) {
+ results.push(
+ {
+ if (cm.current === null) return;
+ const { doc } = cm.current.view.state;
+ const formatted = await format(doc.toString());
+ // Update editor and blur because the cursor will reset anyway
+ cm.current.view.dispatch({
+ changes: { from: 0, to: doc.length, insert: formatted },
+ });
+ cm.current.view.contentDOM.blur();
+ // Fire change event
+ onChange?.(formatted);
+ }}
+ />,
+ );
+ }
+ results.push(
+ Children.map(actions, (existingChild) => {
+ if (!isValidElement<{ className?: string }>(existingChild)) return null;
+ const existingProps = existingChild.props;
+
+ return cloneElement(existingChild, {
+ ...existingProps,
+ className: classNames(existingProps.className, actionClassName),
+ });
+ }),
+ );
+ return results;
+ }, [actions, format, onChange]);
+
+ const cmContainer = (
+
+ );
+
+ if (singleLine || containerOnly) {
+ return cmContainer;
+ }
+
+ return (
+
+ {cmContainer}
+ {decoratedActions && (
+
+ {decoratedActions}
+
+ )}
+
+ );
+}
+
+function getExtensions({
+ stateKey,
+ container,
+ singleLine,
+ hideGutter,
+ onChange,
+ onPaste,
+ onPasteOverwrite,
+ onFocus,
+ onBlur,
+ onKeyDown,
+}: Pick & {
+ stateKey: EditorProps["stateKey"];
+ container: HTMLDivElement | null;
+ onChange: RefObject;
+ onPaste: RefObject;
+ onPasteOverwrite: RefObject;
+ onFocus: RefObject;
+ onBlur: RefObject;
+ onKeyDown: RefObject;
+}) {
+ // TODO: Ensure tooltips render inside the dialog if we are in one.
+ const parent =
+ container?.closest('[role="dialog"]') ??
+ document.querySelector("#cm-portal") ??
+ undefined;
+
+ return [
+ ...baseExtensions, // Must be first
+ EditorView.contentAttributes.of({
+ autocapitalize: "off",
+ autocorrect: "off",
+ // Keeps macOS Writing Tools from offering to write code for us
+ writingsuggestions: "false",
+ }),
+ EditorView.domEventHandlers({
+ focus: () => {
+ onFocus.current?.();
+ },
+ blur: () => {
+ onBlur.current?.();
+ },
+ keydown: (e, view) => {
+ // Check if the hotkey matches the editor.autocomplete action
+ if (eventMatchesHotkey(e, "editor.autocomplete")) {
+ e.preventDefault();
+ startCompletion(view);
+ return true;
+ }
+ onKeyDown.current?.(e);
+ },
+ paste: (e, v) => {
+ const textData = e.clipboardData?.getData("text/plain") ?? "";
+ onPaste.current?.(textData);
+ if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
+ onPasteOverwrite.current?.(e, textData);
+ }
+ },
+ }),
+ tooltips({ parent }),
+ keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
+ ...(singleLine ? [singleLineExtensions()] : []),
+ ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
+
+ // ------------------------ //
+ // Things that must be last //
+ // ------------------------ //
+
+ EditorView.updateListener.of((update) => {
+ if (update.startState === update.state) return;
+
+ if (onChange && update.docChanged) {
+ onChange.current?.(update.state.doc.toString());
+ }
+
+ saveCachedEditorStateDebounced(stateKey, update.state);
+ }),
+ ];
+}
+
+const placeholderElFromText = (text: string | undefined) => {
+ const el = document.createElement("div");
+ // Default to because codemirror needs it for sizing. I'm not sure why, but probably something
+ // to do with how Yaak "hacks" it with CSS for single line input.
+ el.innerHTML = text ? text.replaceAll("\n", " ") : " ";
+ return el;
+};
+
+// Caching the state is too expensive to do on every update (every keystroke and cursor move),
+// so debounce it per state key and flush when the editor unmounts.
+//
+// The cost scales with the document, and every update pays it in full:
+// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
+// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
+// only samples read-only ones, so editing a large body still hashes all of it
+// - `sessionStorage.setItem` is synchronous and blocks the main thread
+// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
+// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
+// half once documents get into the megabytes.
+const SAVE_STATE_DEBOUNCE_MS = 500;
+const stateSavers = new Map>();
+
+function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
+ if (!stateKey) return;
+ let saver = stateSavers.get(stateKey);
+ if (saver == null) {
+ saver = debounce(
+ (s: EditorState) => saveCachedEditorState(stateKey, s),
+ SAVE_STATE_DEBOUNCE_MS,
+ );
+ stateSavers.set(stateKey, saver);
+ }
+ saver(state);
+}
+
+// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
+// for every state key the session has ever shown. A pending saver holds the last EditorState,
+// which holds the whole document.
+function flushCachedEditorState(stateKey: string | null) {
+ if (!stateKey) return;
+ const saver = stateSavers.get(stateKey);
+ if (saver == null) return;
+ saver.flush();
+ stateSavers.delete(stateKey);
+}
+
+function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
+ if (!stateKey || state == null) return;
+ const stateObj = state.toJSON(stateFields);
+
+ // Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
+ // This will be checked on restore and put back in if it matches.
+ // Editable documents get the exact hash, so a collision can never restore undo history
+ // belonging to other content
+ stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
+ stateObj.doc = undefined;
+
+ try {
+ sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
+ } catch (err) {
+ console.log("Failed to save to editor state", stateKey, err);
+ }
+}
+
+function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
+ if (stateKey == null) return;
+
+ try {
+ const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
+ if (stateStr == null) return null;
+
+ const { docHash, ...state } = JSON.parse(stateStr);
+
+ // Ensure the doc matches the one that was used to save the state
+ if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
+ return null;
+ }
+
+ state.doc = doc;
+ return state;
+ } catch (err) {
+ console.log("Failed to restore editor storage", stateKey, err);
+ }
+
+ return null;
+}
+
+function computeFullStateKey(stateKey: string): string {
+ return `editor.${stateKey}`;
+}
+
+function updateContents(view: EditorView, text: string) {
+ // Replace codemirror contents
+ const currentDoc = view.state.doc.toString();
+
+ if (currentDoc === text) {
+ return;
+ }
+
+ if (text.startsWith(currentDoc)) {
+ // If we're just appending, append only the changes. This preserves
+ // things like scroll position.
+ view.dispatch({
+ changes: view.state.changes({
+ from: currentDoc.length,
+ insert: text.slice(currentDoc.length),
+ }),
+ });
+ } else {
+ // If we're replacing everything, reset the entire content
+ view.dispatch({
+ changes: view.state.changes({
+ from: 0,
+ to: currentDoc.length,
+ insert: text,
+ }),
+ });
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/LazyEditor.tsx b/apps/yaak-client/components/core/Editor/LazyEditor.tsx
new file mode 100644
index 0000000..93a2706
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/LazyEditor.tsx
@@ -0,0 +1,12 @@
+import { lazy, Suspense } from "react";
+import type { EditorProps } from "./Editor";
+
+const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
+
+export function Editor(props: EditorProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts
new file mode 100644
index 0000000..29be56c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/extensions.ts
@@ -0,0 +1,343 @@
+import {
+ autocompletion,
+ closeBrackets,
+ closeBracketsKeymap,
+ completionKeymap,
+} from "@codemirror/autocomplete";
+import { history, historyKeymap } from "@codemirror/commands";
+import { go } from "@codemirror/lang-go";
+import { java } from "@codemirror/lang-java";
+import { javascript } from "@codemirror/lang-javascript";
+import { markdown } from "@codemirror/lang-markdown";
+import { php } from "@codemirror/lang-php";
+import { python } from "@codemirror/lang-python";
+import { xml } from "@codemirror/lang-xml";
+import {
+ bracketMatching,
+ codeFolding,
+ foldGutter,
+ foldKeymap,
+ HighlightStyle,
+ indentOnInput,
+ LanguageSupport,
+ StreamLanguage,
+ syntaxHighlighting,
+} from "@codemirror/language";
+import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
+import { clojure } from "@codemirror/legacy-modes/mode/clojure";
+import { http } from "@codemirror/legacy-modes/mode/http";
+import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
+import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
+import { r } from "@codemirror/legacy-modes/mode/r";
+import { ruby } from "@codemirror/legacy-modes/mode/ruby";
+import { shell } from "@codemirror/legacy-modes/mode/shell";
+import { swift } from "@codemirror/legacy-modes/mode/swift";
+import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
+import { search, searchKeymap } from "@codemirror/search";
+import type { Extension } from "@codemirror/state";
+import { EditorState } from "@codemirror/state";
+import {
+ crosshairCursor,
+ drawSelection,
+ dropCursor,
+ EditorView,
+ highlightActiveLineGutter,
+ highlightSpecialChars,
+ keymap,
+ lineNumbers,
+ rectangularSelection,
+} from "@codemirror/view";
+import { tags as t } from "@lezer/highlight";
+import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
+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 { pluralizeCount } from "../../../lib/pluralize";
+import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
+import type { EditorProps } from "./Editor";
+import { jsonParseLinter } from "./json-lint";
+import { largeValues, mediaValues } from "./largeValues";
+import { pairs } from "./pairs/extension";
+import { searchMatchCount } from "./searchMatchCount";
+import { text } from "./text/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";
+
+export const syntaxHighlightStyle = HighlightStyle.define([
+ {
+ tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
+ color: "var(--textSubtlest)",
+ },
+ {
+ tag: [t.emphasis],
+ textDecoration: "underline",
+ },
+ {
+ tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
+ color: "var(--textSubtle)",
+ },
+ {
+ tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
+ color: "var(--info)",
+ },
+ { tag: [t.variableName], color: "var(--success)" },
+ { tag: [t.bool], color: "var(--warning)" },
+ { tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
+ { tag: [t.attributeValue], color: "var(--warning)" },
+ { tag: [t.string], color: "var(--notice)" },
+ { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
+]);
+
+const syntaxTheme = EditorView.theme({}, { dark: true });
+
+const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
+
+const legacyLang = (mode: Parameters[0]) => {
+ return () => new LanguageSupport(StreamLanguage.define(mode));
+};
+
+const syntaxExtensions: Record<
+ NonNullable,
+ null | (() => LanguageSupport)
+> = {
+ graphql: null,
+ json: jsonc,
+ javascript: javascript,
+ // HTML as XML because HTML is oddly slow
+ html: xml,
+ xml: xml,
+ url: url,
+ pairs: pairs,
+ text: text,
+ timeline: timeline,
+ markdown: markdown,
+ c: legacyLang(c),
+ clojure: legacyLang(clojure),
+ csharp: legacyLang(csharp),
+ go: go,
+ http: legacyLang(http),
+ java: java,
+ kotlin: legacyLang(kotlin),
+ objective_c: legacyLang(objectiveC),
+ ocaml: legacyLang(oCaml),
+ php: php,
+ powershell: legacyLang(powerShell),
+ python: python,
+ r: legacyLang(r),
+ ruby: legacyLang(ruby),
+ shell: legacyLang(shell),
+ swift: legacyLang(swift),
+};
+
+const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
+
+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) {
+ const extraExtensions: Extension[] = [];
+
+ if (language === "url") {
+ extraExtensions.push(pathParametersPlugin(onClickPathParameter));
+ }
+
+ // Only close brackets on languages that need it
+ if (language && closeBracketsFor.includes(language)) {
+ extraExtensions.push(closeBracketsExtensions);
+ }
+
+ // GraphQL is a special exception
+ if (language === "graphql") {
+ return [
+ graphql(graphQLSchema ?? undefined, {
+ async onCompletionInfoRender(gqlCompletionItem): Promise {
+ 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 },
+ }));
+ },
+ }),
+ extraExtensions,
+ ];
+ }
+
+ if (language === "json") {
+ extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
+ extraExtensions.push(
+ jsoncLanguage.data.of({
+ commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
+ }),
+ );
+ if (!hideGutter) {
+ extraExtensions.push(lintGutter());
+ }
+ }
+
+ const maybeBase = language ? syntaxExtensions[language] : null;
+ const base = typeof maybeBase === "function" ? maybeBase() : null;
+ if (base == null) {
+ return [];
+ }
+
+ if (!useTemplating) {
+ return [base, extraExtensions];
+ }
+
+ return twig({
+ base,
+ environmentVariables,
+ completionOptions,
+ autocomplete,
+ onClickVariable,
+ onClickMissingVariable,
+ onClickPathParameter,
+ extraExtensions,
+ });
+}
+
+// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
+// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
+const filteredCompletionKeymap = completionKeymap.filter((binding) => {
+ const key = binding.key?.toLowerCase() ?? "";
+ const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
+ // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
+ const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
+ return !isStartTrigger;
+});
+
+export const baseExtensions = [
+ highlightSpecialChars(),
+ history(),
+ dropCursor(),
+ drawSelection(),
+ autocompletion({
+ tooltipClass: () => "x-theme-menu",
+ closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
+ defaultKeymap: false, // We handle the trigger via configurable hotkeys
+ compareCompletions: (a, b) => {
+ // Don't sort completions at all, only on boost
+ return (a.boost ?? 0) - (b.boost ?? 0);
+ },
+ }),
+ syntaxHighlighting(syntaxHighlightStyle),
+ syntaxTheme,
+ keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
+];
+
+export const readonlyExtensions = [
+ EditorState.readOnly.of(true),
+ EditorView.contentAttributes.of({ tabindex: "-1" }),
+ // Nobody is editing this, so every rule applies
+ largeValues,
+];
+
+/**
+ * The counterpart for a document being edited.
+ *
+ * Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
+ * edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
+ * hide text by length alone stay out. See {@link mediaValues}.
+ */
+export const editableExtensions = [mediaValues];
+
+export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
+ search({ top: true }),
+ searchMatchCount(),
+ hideGutter
+ ? []
+ : [
+ lineNumbers(),
+ foldGutter({
+ markerDOM: (open) => {
+ const el = document.createElement("div");
+ el.classList.add("fold-gutter-icon");
+ el.tabIndex = -1;
+ if (open) {
+ el.setAttribute("data-open", "");
+ }
+ return el;
+ },
+ }),
+ ],
+ codeFolding({
+ placeholderDOM(_view, onclick, prepared) {
+ const el = document.createElement("span");
+ el.onclick = onclick;
+ el.className = "cm-foldPlaceholder";
+ el.innerText = prepared || "…";
+ el.title = "unfold";
+ el.ariaLabel = "folded code";
+ return el;
+ },
+ /**
+ * Show the number of items when code folded. NOTE: this doesn't get called when restoring
+ * a previous serialized editor state, which is a bummer
+ */
+ preparePlaceholder(state, range) {
+ let count: number | undefined;
+ let startToken = "{";
+ let endToken = "}";
+
+ const prevLine = state.doc.lineAt(range.from).text;
+ const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
+
+ if (isArray) {
+ startToken = "[";
+ endToken = "]";
+ }
+
+ const internal = state.sliceDoc(range.from, range.to);
+ const toParse = startToken + internal + endToken;
+
+ try {
+ const parsed = JSON.parse(toParse);
+ count = Object.keys(parsed).length;
+ } catch {
+ /* empty */
+ }
+
+ if (count !== undefined) {
+ const label = isArray ? "item" : "key";
+ return pluralizeCount(label, count);
+ }
+ },
+ }),
+ indentOnInput(),
+ rectangularSelection(),
+ crosshairCursor(),
+ bracketMatching(),
+ highlightActiveLineGutter(),
+ keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
+];
diff --git a/apps/yaak-client/components/core/Editor/filter/extension.ts b/apps/yaak-client/components/core/Editor/filter/extension.ts
new file mode 100644
index 0000000..f98c608
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/extension.ts
@@ -0,0 +1,211 @@
+import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
+import { autocompletion, startCompletion } from "@codemirror/autocomplete";
+import { LanguageSupport, LRLanguage, syntaxTree } from "@codemirror/language";
+import type { SyntaxNode } from "@lezer/common";
+import { parser } from "./filter";
+
+export interface FieldDef {
+ name: string;
+ // Optional static or dynamic value suggestions for this field
+ values?: string[] | (() => string[]);
+ info?: string;
+}
+
+export interface FilterOptions {
+ fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
+}
+
+const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
+const VALUE_IDENT = /\S+$/;
+const VALUE_IDENT_ONLY = /^\S+$/;
+
+function normalizeFields(fields: FieldDef[]): {
+ fieldNames: string[];
+ fieldMap: Record string[]); info?: string }>;
+} {
+ const fieldNames: string[] = [];
+ const fieldMap: Record string[]); info?: string }> = {};
+ for (const f of fields) {
+ fieldNames.push(f.name);
+ fieldMap[f.name] = { values: f.values, info: f.info };
+ }
+ return { fieldNames, fieldMap };
+}
+
+function wordBefore(
+ doc: string,
+ pos: number,
+ pattern: RegExp,
+): { from: number; to: number; text: string } | null {
+ const upto = doc.slice(0, pos);
+ const m = upto.match(pattern);
+ if (!m) return null;
+ const from = pos - m[0].length;
+ return { from, to: pos, text: m[0] };
+}
+
+function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
+ const w = wordBefore(doc, pos, FIELD_IDENT);
+ const from = w?.from ?? pos;
+ const beforeToken = doc[from - 1];
+
+ if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
+ return { from, includeAt: true };
+ }
+
+ if (beforeToken === "@") {
+ const beforeAt = doc[from - 2];
+ if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
+ return { from, includeAt: false };
+ }
+ }
+
+ return null;
+}
+
+function inPhrase(ctx: CompletionContext): boolean {
+ // Lezer node names from your grammar: Phrase is the quoted token
+ let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
+ while (n) {
+ if (n.name === "Phrase") return true;
+ n = n.parent;
+ }
+ return false;
+}
+
+// While typing an incomplete quote, there's no Phrase token yet.
+function inUnclosedQuote(doc: string, pos: number): boolean {
+ let quotes = 0;
+ for (let i = 0; i < pos; i++) {
+ if (doc[i] === '"' && doc[i - 1] !== "\\") quotes++;
+ }
+ return quotes % 2 === 1; // odd = inside an open quote
+}
+
+/**
+ * Heuristic context detector (works without relying on exact node names):
+ * - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
+ * - Otherwise, we're in a field name or bare term position.
+ */
+function contextInfo(stateDoc: string, pos: number) {
+ const lastColon = stateDoc.lastIndexOf(":", pos - 1);
+ const lastBoundary = Math.max(
+ stateDoc.lastIndexOf(" ", pos - 1),
+ stateDoc.lastIndexOf("\t", pos - 1),
+ stateDoc.lastIndexOf("\n", pos - 1),
+ stateDoc.lastIndexOf("(", pos - 1),
+ stateDoc.lastIndexOf(")", pos - 1),
+ );
+
+ const inValue = lastColon > lastBoundary;
+
+ let fieldName: string | null = null;
+ let emptyAfterColon = false;
+
+ if (inValue) {
+ // word before the colon = field name
+ const beforeColon = stateDoc.slice(0, lastColon);
+ const m = beforeColon.match(FIELD_IDENT);
+ fieldName = m ? m[0] : null;
+
+ // nothing (or only spaces) typed after the colon?
+ const after = stateDoc.slice(lastColon + 1, pos);
+ emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
+ }
+
+ return { inValue, fieldName, lastColon, emptyAfterColon };
+}
+
+/** Build a completion list for field names */
+function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
+ return fieldNames.map((name) => ({
+ label: name,
+ type: "property",
+ apply: (view, _completion, from, to) => {
+ // Leave cursor right after the field filter colon.
+ const insert = `${includeAt ? "@" : ""}${name}:`;
+ view.dispatch({
+ changes: { from, to, insert },
+ selection: { anchor: from + insert.length },
+ });
+ startCompletion(view);
+ },
+ }));
+}
+
+/** Build a completion list for field values (if provided) */
+function fieldValueCompletions(
+ def: { values?: string[] | (() => string[]); info?: string } | undefined,
+): Completion[] | null {
+ if (!def || !def.values) return null;
+ const vals = Array.isArray(def.values) ? def.values : def.values();
+ return vals.map((v) => ({
+ label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
+ displayLabel: v,
+ type: "constant",
+ }));
+}
+
+/** The main completion source */
+function makeCompletionSource(opts: FilterOptions) {
+ const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
+ return (ctx: CompletionContext): CompletionResult | null => {
+ const { state, pos } = ctx;
+ const doc = state.doc.toString();
+
+ if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
+ return null;
+ }
+
+ const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
+
+ // In field value position
+ if (inValue && fieldName) {
+ const w = wordBefore(doc, pos, VALUE_IDENT);
+ const from = w?.from ?? pos;
+ const to = pos;
+ const valDefs = fieldMap[fieldName];
+ const vals = fieldValueCompletions(valDefs);
+
+ // If user hasn't typed a value char yet:
+ // - Show value suggestions if available
+ // - Otherwise show nothing (no fallback to field names)
+ if (emptyAfterColon) {
+ if (vals?.length) {
+ return { from, to, options: vals, filter: true };
+ }
+ return null; // <-- key change: do not suggest fields here
+ }
+
+ // User started typing a value; filter value suggestions (if any)
+ if (vals?.length) {
+ return { from, to, options: vals, filter: true };
+ }
+ // No specific values: also show nothing (keeps UI quiet)
+ return null;
+ }
+
+ // Not in a value: suggest field names (and maybe boolean ops)
+ const completion = fieldCompletionFrom(doc, pos);
+ if (completion == null) return null;
+ const { from, includeAt } = completion;
+ const to = pos;
+ const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
+
+ return { from, to, options, filter: true };
+ };
+}
+
+const language = LRLanguage.define({
+ name: "filter",
+ parser,
+ languageData: {
+ autocompletion: {},
+ },
+});
+
+/** Public extension */
+export function filter(options: FilterOptions) {
+ const source = makeCompletionSource(options);
+ return new LanguageSupport(language, [autocompletion({ override: [source] })]);
+}
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.grammar b/apps/yaak-client/components/core/Editor/filter/filter.grammar
new file mode 100644
index 0000000..dcf8b81
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.grammar
@@ -0,0 +1,78 @@
+@top Query { Expr }
+
+@skip { space+ }
+@tokens {
+ space { $[ \t\r\n]+ }
+
+ LParen { "(" }
+ RParen { ")" }
+ At { "@" }
+ Colon { ":" }
+ Not { "-" | "NOT" }
+
+ // Keywords (case-insensitive)
+ And { "AND" }
+ Or { "OR" }
+
+ // "quoted phrase" with simple escapes: \" and \\
+ Phrase { '"' (!["\\] | "\\" _)* '"' }
+
+ // Bare words run until filter syntax or whitespace. Leading '-' remains unary
+ // negation, but '-' may appear after the first character.
+ Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
+ FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
+
+ @precedence { Not, And, Or, Word }
+}
+
+@detectDelim
+
+// Precedence: NOT (highest) > AND > OR (lowest)
+// We also allow implicit AND in your parser/evaluator, but for highlighting,
+// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
+Expr {
+ OrExpr
+}
+
+OrExpr {
+ AndExpr (Or AndExpr)*
+}
+
+AndExpr {
+ Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
+}
+
+Unary {
+ Not Unary
+| Primary
+}
+
+Primary {
+ Group
+| Field
+| Phrase
+| Term
+}
+
+Group {
+ LParen Expr RParen
+}
+
+Field {
+ FieldName Colon FieldValue
+}
+
+FieldName {
+ At? Word
+}
+
+FieldValue {
+ Phrase
+| FieldValueWord
+}
+
+Term {
+ Word
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.test.ts b/apps/yaak-client/components/core/Editor/filter/filter.test.ts
new file mode 100644
index 0000000..6002175
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./filter";
+
+function getNodeNames(input: string): string[] {
+ const tree = parser.parse(input);
+ const nodes: string[] = [];
+ const cursor = tree.cursor();
+ do {
+ if (cursor.name !== "Query") {
+ nodes.push(cursor.name);
+ }
+ } while (cursor.next());
+ return nodes;
+}
+
+describe("filter grammar", () => {
+ test("parses URL-like field values as one value", () => {
+ const nodes = getNodeNames("@url:yaak.app/foo-bar");
+
+ expect(nodes).not.toContain("⚠");
+ expect(nodes).toContain("FieldValue");
+ expect(nodes).toContain("FieldValueWord");
+ });
+
+ test("parses punctuation-heavy field values as one value", () => {
+ const nodes = getNodeNames("@url:yaa$*@tsrna(*)");
+
+ expect(nodes).not.toContain("⚠");
+ expect(nodes).toContain("FieldValue");
+ expect(nodes).toContain("FieldValueWord");
+ });
+
+ test("parses operator-looking field values as one value", () => {
+ const negativeValueNodes = getNodeNames("@url:-foo");
+ const operatorWordNodes = getNodeNames("@url:AND");
+
+ expect(negativeValueNodes).not.toContain("⚠");
+ expect(negativeValueNodes).toContain("FieldValueWord");
+ expect(operatorWordNodes).not.toContain("⚠");
+ expect(operatorWordNodes).toContain("FieldValueWord");
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.ts b/apps/yaak-client/components/core/Editor/filter/filter.ts
new file mode 100644
index 0000000..717a6da
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.ts
@@ -0,0 +1,22 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import {LRParser} from "@lezer/lr"
+import {highlight} from "./highlight"
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
+ stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
+ goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
+ nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
+ maxTerm: 27,
+ nodeProps: [
+ ["openedBy", 8,"LParen"],
+ ["closedBy", 9,"RParen"]
+ ],
+ propSources: [highlight],
+ skippedNodes: [0,22],
+ repeatNodeCount: 3,
+ tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
+ tokenizers: [0, 1],
+ topRules: {"Query":[0,1]},
+ tokenPrec: 145
+})
diff --git a/apps/yaak-client/components/core/Editor/filter/format.test.ts b/apps/yaak-client/components/core/Editor/filter/format.test.ts
new file mode 100644
index 0000000..07ba7af
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/format.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vite-plus/test";
+import { formatFieldFilter } from "./format";
+import { evaluate, parseQuery } from "./query";
+
+function matchesFormattedUrl(value: string) {
+ return evaluate(parseQuery(formatFieldFilter("url", value)), {
+ fields: { url: value },
+ });
+}
+
+describe("formatFieldFilter", () => {
+ test("keeps URL-like values bare", () => {
+ expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
+ expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
+ });
+
+ test("keeps non-syntax punctuation bare", () => {
+ expect(formatFieldFilter("url", "yaa$*@tsrna(*)")).toBe("@url:yaa$*@tsrna(*)");
+ expect(matchesFormattedUrl("yaa$*@tsrna(*)")).toBe(true);
+ });
+
+ test("keeps values that start with an operator token bare", () => {
+ expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
+ expect(matchesFormattedUrl("-foo")).toBe(true);
+ });
+
+ test("keeps boolean operator words bare", () => {
+ expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
+ expect(formatFieldFilter("url", "or")).toBe("@url:or");
+ expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
+ expect(matchesFormattedUrl("AND")).toBe(true);
+ });
+
+ test("escapes quoted values", () => {
+ expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
+ expect(matchesFormattedUrl('say "hi"')).toBe(true);
+ });
+
+ test("quotes values that start with a quote", () => {
+ expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
+ expect(matchesFormattedUrl('"hi"')).toBe(true);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/format.ts b/apps/yaak-client/components/core/Editor/filter/format.ts
new file mode 100644
index 0000000..f1fc721
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/format.ts
@@ -0,0 +1,7 @@
+const bareFieldValue = /^[^\s"]\S*$/;
+
+export function formatFieldFilter(field: string, value: string) {
+ const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
+ return `@${field}:${filterValue}`;
+}
diff --git a/apps/yaak-client/components/core/Editor/filter/highlight.ts b/apps/yaak-client/components/core/Editor/filter/highlight.ts
new file mode 100644
index 0000000..9c05d72
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/highlight.ts
@@ -0,0 +1,22 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ // Boolean operators
+ And: t.operatorKeyword,
+ Or: t.operatorKeyword,
+ Not: t.operatorKeyword,
+
+ // Structural punctuation
+ LParen: t.paren,
+ RParen: t.paren,
+ Colon: t.punctuation,
+ Minus: t.operator,
+
+ // Literals
+ Phrase: t.string, // "quoted string"
+
+ // Fields
+ "FieldName/At": t.attributeName,
+ "FieldName/Word": t.attributeName,
+ "FieldValue/FieldValueWord": t.attributeValue,
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/query.ts b/apps/yaak-client/components/core/Editor/filter/query.ts
new file mode 100644
index 0000000..12bf4a4
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/query.ts
@@ -0,0 +1,308 @@
+// query.ts
+// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
+
+import { fuzzyMatch } from "fuzzbunny";
+/////////////////////////
+// AST
+/////////////////////////
+
+export type Ast =
+ | { type: "Term"; value: string } // foo
+ | { type: "Phrase"; value: string } // "hi there"
+ | { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase"
+ | { type: "Not"; node: Ast } // -foo or NOT foo
+ | { type: "And"; left: Ast; right: Ast } // a AND b
+ | { type: "Or"; left: Ast; right: Ast }; // a OR b
+
+/////////////////////////
+// Tokenizer
+/////////////////////////
+type Tok =
+ | { kind: "LPAREN" }
+ | { kind: "RPAREN" }
+ | { kind: "AND" }
+ | { kind: "OR" }
+ | { kind: "NOT" } // explicit NOT
+ | { kind: "MINUS" } // unary minus before term/phrase/paren group
+ | { kind: "COLON" }
+ | { kind: "WORD"; text: string } // bareword (unquoted)
+ | { kind: "PHRASE"; text: string } // "quoted phrase"
+ | { kind: "EOF" };
+
+const isSpace = (c: string) => /\s/.test(c);
+const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
+const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
+
+export function tokenize(input: string): Tok[] {
+ const toks: Tok[] = [];
+ let i = 0;
+ const n = input.length;
+
+ const peek = () => input[i] ?? "";
+ const advance = () => input[i++];
+
+ const readWord = () => {
+ let s = "";
+ while (i < n && isWordChar(peek())) s += advance();
+ return s;
+ };
+
+ const readFieldValue = () => {
+ let s = "";
+ while (i < n && !isSpace(peek())) s += advance();
+ return s;
+ };
+
+ const readPhrase = () => {
+ // assumes current char is opening quote
+ advance(); // consume opening "
+ let s = "";
+ while (i < n) {
+ const c = advance();
+ if (c === `"`) break;
+ if (c === "\\" && i < n) {
+ // escape \" and \\ (simple)
+ const next = advance();
+ s += next;
+ } else {
+ s += c;
+ }
+ }
+ return s;
+ };
+
+ while (i < n) {
+ const c = peek();
+
+ if (isSpace(c)) {
+ i++;
+ continue;
+ }
+
+ if (c === "(") {
+ toks.push({ kind: "LPAREN" });
+ i++;
+ continue;
+ }
+ if (c === ")") {
+ toks.push({ kind: "RPAREN" });
+ i++;
+ continue;
+ }
+ if (c === ":") {
+ toks.push({ kind: "COLON" });
+ i++;
+ if (peek() && !isSpace(peek()) && peek() !== `"`) {
+ toks.push({ kind: "WORD", text: readFieldValue() });
+ }
+ continue;
+ }
+ if (c === `"`) {
+ const text = readPhrase();
+ toks.push({ kind: "PHRASE", text });
+ continue;
+ }
+ if (c === "-") {
+ toks.push({ kind: "MINUS" });
+ i++;
+ continue;
+ }
+
+ // WORD / AND / OR / NOT
+ if (isWordStart(c)) {
+ const w = readWord();
+ const upper = w.toUpperCase();
+ if (upper === "AND") toks.push({ kind: "AND" });
+ else if (upper === "OR") toks.push({ kind: "OR" });
+ else if (upper === "NOT") toks.push({ kind: "NOT" });
+ else toks.push({ kind: "WORD", text: w });
+ continue;
+ }
+
+ // Unknown char—skip to be forgiving
+ i++;
+ }
+
+ toks.push({ kind: "EOF" });
+ return toks;
+}
+
+class Parser {
+ private i = 0;
+ constructor(private toks: Tok[]) {}
+
+ private peek(): Tok {
+ return this.toks[this.i] ?? { kind: "EOF" };
+ }
+ private advance(): Tok {
+ return this.toks[this.i++] ?? { kind: "EOF" };
+ }
+ private at(kind: Tok["kind"]) {
+ return this.peek().kind === kind;
+ }
+
+ // Top-level: parse OR-precedence chain, allowing implicit AND.
+ parse(): Ast | null {
+ if (this.at("EOF")) return null;
+ const expr = this.parseOr();
+ if (!this.at("EOF")) {
+ // Optionally, consume remaining tokens or throw
+ }
+ return expr;
+ }
+
+ // Precedence: NOT (highest), AND, OR (lowest)
+ private parseOr(): Ast {
+ let node = this.parseAnd();
+ while (this.at("OR")) {
+ this.advance();
+ const rhs = this.parseAnd();
+ node = { type: "Or", left: node, right: rhs };
+ }
+ return node;
+ }
+
+ private parseAnd(): Ast {
+ let node = this.parseUnary();
+ // Implicit AND: if next token starts a primary, treat as AND.
+ while (this.at("AND") || this.startsPrimary()) {
+ if (this.at("AND")) this.advance();
+ const rhs = this.parseUnary();
+ node = { type: "And", left: node, right: rhs };
+ }
+ return node;
+ }
+
+ private parseUnary(): Ast {
+ if (this.at("NOT") || this.at("MINUS")) {
+ this.advance();
+ const node = this.parseUnary();
+ return { type: "Not", node };
+ }
+ return this.parsePrimaryOrField();
+ }
+
+ private startsPrimary(): boolean {
+ const k = this.peek().kind;
+ return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
+ }
+
+ private parsePrimaryOrField(): Ast {
+ // Parenthesized group
+ if (this.at("LPAREN")) {
+ this.advance();
+ const inside = this.parseOr();
+ // if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
+ this.advance();
+ return inside;
+ }
+
+ // Phrase
+ if (this.at("PHRASE")) {
+ const t = this.advance() as Extract;
+ return { type: "Phrase", value: t.text };
+ }
+
+ // Field or bare word
+ if (this.at("WORD")) {
+ const wordTok = this.advance() as Extract;
+
+ if (this.at("COLON")) {
+ // field:value or field:"phrase"
+ this.advance(); // :
+ let value: string;
+ if (this.at("PHRASE")) {
+ const p = this.advance() as Extract;
+ value = p.text;
+ } else if (this.at("WORD")) {
+ const w = this.advance() as Extract;
+ value = w.text;
+ } else {
+ // Anything else after colon is treated literally as a single Term token.
+ const t = this.advance();
+ value = tokText(t);
+ }
+ return { type: "Field", field: wordTok.text, value };
+ }
+
+ // plain term
+ return { type: "Term", value: wordTok.text };
+ }
+
+ const w = this.advance() as Extract;
+ return { type: "Phrase", value: "text" in w ? w.text : "" };
+ }
+}
+
+function tokText(t: Tok): string {
+ if ("text" in t) return t.text;
+
+ switch (t.kind) {
+ case "COLON":
+ return ":";
+ case "LPAREN":
+ return "(";
+ case "RPAREN":
+ return ")";
+ default:
+ return "";
+ }
+}
+
+export function parseQuery(q: string): Ast | null {
+ if (q.trim() === "") return null;
+ const toks = tokenize(q);
+ const parser = new Parser(toks);
+ return parser.parse();
+}
+
+export type Doc = {
+ text?: string;
+ fields?: Record;
+};
+
+type Technique = "substring" | "fuzzy" | "strict";
+
+function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
+ if (!hay || !needle) return false;
+ if (technique === "strict") return hay === needle;
+ if (technique === "fuzzy") return !!fuzzyMatch(hay, needle);
+ return hay.indexOf(needle) !== -1;
+}
+
+export function evaluate(ast: Ast | null, doc: Doc): boolean {
+ if (!ast) return true; // Match everything if no query is provided
+
+ const text = (doc.text ?? "").toLowerCase();
+ const fieldsNorm: Record = {};
+
+ for (const [k, v] of Object.entries(doc.fields ?? {})) {
+ if (!(typeof v === "string" || Array.isArray(v))) continue;
+ fieldsNorm[k.toLowerCase()] = Array.isArray(v)
+ ? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase())
+ : [String(v ?? "").toLowerCase()];
+ }
+
+ const evalNode = (node: Ast): boolean => {
+ switch (node.type) {
+ case "Term":
+ return includes(text, node.value.toLowerCase(), "fuzzy");
+ case "Phrase":
+ // Quoted phrases match exactly
+ return includes(text, node.value.toLowerCase(), "substring");
+ case "Field": {
+ const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
+ if (vals.length === 0) return false;
+ return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
+ }
+ case "Not":
+ return !evalNode(node.node);
+ case "And":
+ return evalNode(node.left) && evalNode(node.right);
+ case "Or":
+ return evalNode(node.left) || evalNode(node.right);
+ }
+ };
+
+ return evalNode(ast);
+}
diff --git a/apps/yaak-client/components/core/Editor/genericCompletion.ts b/apps/yaak-client/components/core/Editor/genericCompletion.ts
new file mode 100644
index 0000000..630da4c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/genericCompletion.ts
@@ -0,0 +1,43 @@
+import type { Completion, CompletionContext } from "@codemirror/autocomplete";
+import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
+import { defaultBoost } from "./twig/completion";
+
+export type GenericCompletion = GenericCompletionOption & {
+ apply?: Completion["apply"];
+};
+
+export interface GenericCompletionConfig {
+ minMatch?: number;
+ options: GenericCompletion[];
+}
+
+/**
+ * Complete options, always matching until the start of the line
+ */
+export function genericCompletion(config?: GenericCompletionConfig) {
+ if (config == null) return [];
+
+ const { minMatch = 1, options } = config;
+
+ return function completions(context: CompletionContext) {
+ const toMatch = context.matchBefore(/.*/);
+
+ // Only match if we're at the start of the line
+ if (toMatch === null || toMatch.from > 0) return null;
+
+ const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
+ if (!matchedMinimumLength && !context.explicit) return null;
+
+ const optionsWithoutExactMatches = options
+ .filter((o) => o.label !== toMatch.text)
+ .map((o) => ({
+ ...o,
+ boost: defaultBoost(o),
+ }));
+ return {
+ validFor: () => true, // Not really sure why this is all it needs
+ from: toMatch.from,
+ options: optionsWithoutExactMatches,
+ };
+ };
+}
diff --git a/apps/yaak-client/components/core/Editor/hyperlink/extension.ts b/apps/yaak-client/components/core/Editor/hyperlink/extension.ts
new file mode 100644
index 0000000..7f1f1a9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/hyperlink/extension.ts
@@ -0,0 +1,129 @@
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from "@codemirror/view";
+import { activeWorkspaceIdAtom } from "../../../../hooks/useActiveWorkspace";
+import { copyToClipboard } from "../../../../lib/copy";
+import { createRequestAndNavigate } from "../../../../lib/createRequestAndNavigate";
+import { jotaiStore } from "../../../../lib/jotai";
+
+const REGEX =
+ /(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
+
+const tooltip = hoverTooltip(
+ (view, pos, side) => {
+ const { from, text } = view.state.doc.lineAt(pos);
+ let match: RegExpExecArray | null;
+ let found: { start: number; end: number } | null = null;
+
+ // oxlint-disable-next-line no-cond-assign
+ while ((match = REGEX.exec(text))) {
+ const start = from + match.index;
+ const end = start + match[0].length;
+
+ if (pos >= start && pos <= end) {
+ found = { start, end };
+ break;
+ }
+ }
+
+ if (found == null) {
+ return null;
+ }
+
+ if ((found.start === pos && side < 0) || (found.end === pos && side > 0)) {
+ return null;
+ }
+
+ return {
+ pos: found.start,
+ end: found.end,
+ create() {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const link = text.substring(found?.start - from, found?.end - from);
+ const dom = document.createElement("div");
+
+ const $open = document.createElement("a");
+ $open.textContent = "Open in browser";
+ $open.href = link;
+ $open.target = "_blank";
+ $open.rel = "noopener noreferrer";
+
+ const $copy = document.createElement("button");
+ $copy.textContent = "Copy to clipboard";
+ $copy.addEventListener("click", () => {
+ copyToClipboard(link);
+ });
+
+ const $create = document.createElement("button");
+ $create.textContent = "Create new request";
+ $create.addEventListener("click", async () => {
+ await createRequestAndNavigate({
+ model: "http_request",
+ workspaceId: workspaceId ?? "n/a",
+ url: link,
+ });
+ });
+
+ dom.appendChild($open);
+ dom.appendChild($copy);
+ if (workspaceId != null) {
+ dom.appendChild($create);
+ }
+
+ return { dom };
+ },
+ };
+ },
+ {
+ hoverTime: 150,
+ },
+);
+
+const decorator = () => {
+ const placeholderMatcher = new MatchDecorator({
+ regexp: REGEX,
+ decoration(match, view, matchStartPos) {
+ const matchEndPos = matchStartPos + match[0].length - 1;
+
+ // Don't decorate if the cursor is inside the match
+ for (const r of view.state.selection.ranges) {
+ if (r.from > matchStartPos && r.to <= matchEndPos) {
+ return Decoration.replace({});
+ }
+ }
+
+ const groupMatch = match[1];
+ if (groupMatch == null) {
+ // Should never happen, but make TS happy
+ console.warn("Group match was empty", match);
+ return Decoration.replace({});
+ }
+
+ return Decoration.mark({
+ class: "hyperlink-widget",
+ });
+ },
+ });
+
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = placeholderMatcher.createDeco(view);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
+ }
+ },
+ {
+ decorations: (instance) => instance.decorations,
+ provide: (plugin) =>
+ EditorView.bidiIsolatedRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ }),
+ },
+ );
+};
+
+export const hyperlink = [tooltip, decorator()];
diff --git a/apps/yaak-client/components/core/Editor/json-lint.ts b/apps/yaak-client/components/core/Editor/json-lint.ts
new file mode 100644
index 0000000..1a46bd2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json-lint.ts
@@ -0,0 +1,60 @@
+import type { Diagnostic } from "@codemirror/lint";
+import type { EditorView } from "@codemirror/view";
+import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
+
+const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
+
+// jsonc-parser reports error codes, so these are the words the editor shows for them
+const MESSAGES: Record = {
+ InvalidSymbol: "Invalid symbol",
+ InvalidNumberFormat: "Invalid number format",
+ PropertyNameExpected: "Property name expected",
+ ValueExpected: "Value expected",
+ ColonExpected: "Colon expected",
+ CommaExpected: "Comma expected",
+ CloseBraceExpected: "Closing brace expected",
+ CloseBracketExpected: "Closing bracket expected",
+ EndOfFileExpected: "End of file expected",
+ InvalidCommentToken: "Comments are not allowed",
+ UnexpectedEndOfComment: "Unexpected end of comment",
+ UnexpectedEndOfString: "Unexpected end of string",
+ UnexpectedEndOfNumber: "Unexpected end of number",
+ InvalidUnicode: "Invalid unicode sequence",
+ InvalidEscapeCharacter: "Invalid escape character",
+ InvalidCharacter: "Invalid character",
+};
+
+interface JsonLintOptions {
+ allowComments?: boolean;
+ allowTrailingCommas?: boolean;
+}
+
+export function jsonParseLinter(options?: JsonLintOptions) {
+ return (view: EditorView): Diagnostic[] => {
+ const doc = view.state.doc.toString();
+ if (doc.trim() === "") return [];
+
+ // We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
+ // syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
+ const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
+
+ const errors: ParseError[] = [];
+ parse(escapedDoc, errors, {
+ allowTrailingComma: options?.allowTrailingCommas ?? false,
+ disallowComments: !(options?.allowComments ?? true),
+ });
+
+ // Later errors are mostly consequences of the first one, so only that one is shown
+ const error = errors[0];
+ if (error == null) return [];
+
+ return [
+ {
+ from: error.offset,
+ to: error.offset + error.length,
+ severity: "error",
+ message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
+ },
+ ];
+ };
+}
diff --git a/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts b/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts
new file mode 100644
index 0000000..67cd59e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts
@@ -0,0 +1,41 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Extension } from "@codemirror/state";
+import type { EditorView, ViewUpdate } from "@codemirror/view";
+import { ViewPlugin } from "@codemirror/view";
+import type { JsonPathSegment } from "./jsonPath";
+import { jsonPathSegmentsAt } from "./jsonPath";
+
+export interface BreadcrumbUpdate {
+ /** Path to the cursor, or `null` when the document isn't JSON. */
+ segments: JsonPathSegment[] | null;
+}
+
+/**
+ * Reports the JSON path under the cursor when the editor is created (it may restore a
+ * cached selection), and whenever the selection, document, or parsed tree changes. The
+ * tree matters because large documents parse in the background, so a path read early
+ * can be wrong until the parser reaches the cursor.
+ *
+ * The callback is not fired on blur: moving focus to the filter box (by clicking
+ * a crumb) must not erase the breadcrumb that was just acted on.
+ */
+export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
+ const report = (view: EditorView) => {
+ onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
+ };
+
+ return ViewPlugin.define((view) => {
+ report(view);
+ return {
+ update(update: ViewUpdate) {
+ if (
+ update.selectionSet ||
+ update.docChanged ||
+ syntaxTree(update.startState) !== syntaxTree(update.state)
+ ) {
+ report(update.view);
+ }
+ },
+ };
+ });
+}
diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
new file mode 100644
index 0000000..a578c45
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
@@ -0,0 +1,136 @@
+import { EditorState } from "@codemirror/state";
+import { jsonc } from "@shopify/lang-jsonc";
+import { forceParsing } from "@codemirror/language";
+import { describe, expect, test } from "vite-plus/test";
+import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
+
+function stateFor(doc: string): EditorState {
+ const state = EditorState.create({ doc, extensions: [jsonc()] });
+ // Ensure the whole doc is parsed so resolveInner sees a complete tree.
+ forceParsing({ state } as never, doc.length, 5000);
+ return state;
+}
+
+/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
+function pathAt(doc: string, needle: string): string {
+ const pos = doc.indexOf(needle) + needle.length;
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ if (segs == null) throw new Error("no JSON tree");
+ return segmentsToJsonPath(segs);
+}
+
+/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
+function pathBefore(doc: string, needle: string): string {
+ const pos = doc.indexOf(needle);
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ if (segs == null) throw new Error("no JSON tree");
+ return segmentsToJsonPath(segs);
+}
+
+describe("jsonPathSegmentsAt", () => {
+ test("nested object keys", () => {
+ const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
+ expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
+ });
+
+ test("array element index", () => {
+ const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
+ expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
+ });
+
+ test("root array element", () => {
+ const doc = `[ { "id": 1 }, { "id": 2 } ]`;
+ expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
+ });
+
+ test("key containing a dot uses bracket-quote notation", () => {
+ const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
+ expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
+ });
+
+ test("cursor on a property name resolves that key", () => {
+ const doc = `{ "outer": { "inner": 5 } }`;
+ expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
+ });
+
+ test("deeply nested arrays and objects", () => {
+ const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
+ expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
+ });
+
+ test("root scalar yields the root path", () => {
+ expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
+ });
+
+ test("caret just before a key still resolves that key", () => {
+ // The character to the left is the object's whitespace, not the property.
+ const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
+ expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
+ });
+
+ test("caret in a line's indentation resolves that line's member", () => {
+ // Caret in the blank indentation of the `title` line (structurally the
+ // object's whitespace) reads as being on `title`, not a neighbour.
+ const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
+ const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
+ });
+
+ test("caret at the end of an array element keeps the element index", () => {
+ // The caret sits after `}` where the left token is the object's close brace.
+ const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
+ const pos = doc.indexOf(`},`) + 1;
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
+ });
+
+ test("non-JSON documents return null", () => {
+ const state = EditorState.create({ doc: ` ` });
+ expect(jsonPathSegmentsAt(state, 3)).toBeNull();
+ });
+});
+
+describe("segmentsToJsonPath", () => {
+ test("empty segment list is the root", () => {
+ expect(segmentsToJsonPath([])).toBe(`$`);
+ });
+
+ test("partial path via count", () => {
+ const segs = [
+ { kind: "key", key: "a" },
+ { kind: "index", index: 2 },
+ { kind: "key", key: "b" },
+ ] as const;
+ expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
+ });
+});
+
+describe("jsonPathToSegments", () => {
+ test("round-trips paths built by segmentsToJsonPath", () => {
+ for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
+ const segments = jsonPathToSegments(path);
+ expect(segments).not.toBeNull();
+ expect(segmentsToJsonPath(segments ?? [])).toBe(path);
+ }
+ });
+
+ test("returns null for paths that don't name a single location", () => {
+ for (const path of [
+ "$..id",
+ "$[*]",
+ "$.items[?(@.id)]",
+ "$[0:2]",
+ "items[0]",
+ "$.a b",
+ '$["\\q"]',
+ '$["unterminated]',
+ '$["line\nbreak"]',
+ "$[01]",
+ "$[9007199254740993]",
+ "$.$",
+ ]) {
+ expect(jsonPathToSegments(path)).toBeNull();
+ }
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.ts
new file mode 100644
index 0000000..ab77e62
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/jsonPath.ts
@@ -0,0 +1,102 @@
+import { syntaxTree } from "@codemirror/language";
+import type { EditorState } from "@codemirror/state";
+import type { SyntaxNode } from "@lezer/common";
+import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
+
+export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
+export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
+
+// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
+// exactly one of these; everything else in the tree is punctuation or a comment.
+const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
+// The nodes with children that a caret can land "between" — its whitespace and
+// punctuation belong to the container, not to any value inside it.
+const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
+
+/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
+function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
+ const raw = state.doc.sliceString(node.from, node.to);
+ try {
+ return JSON.parse(raw) as string;
+ } catch {
+ // A half-typed or malformed key still deserves a best-effort label.
+ return raw.replace(/^"|"$/g, "");
+ }
+}
+
+/** Index of `child` among the value elements of its parent `Array` node. */
+function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
+ let idx = 0;
+ for (let c = array.firstChild; c != null; c = c.nextSibling) {
+ if (c.from === child.from && c.to === child.to) return idx;
+ if (VALUE_NODES.has(c.name)) idx++;
+ }
+ return idx;
+}
+
+/**
+ * The path from the document root to the member on the caret's line, or `null`
+ * when the document has no JSON tree (a different language, or empty). An empty
+ * array means the caret sits at the root value itself.
+ *
+ * A caret exactly on a token uses that token. A caret in a line's whitespace
+ * resolves to the enclosing object or array, so it's re-anchored to the first
+ * non-whitespace character on that line — in pretty-printed JSON each member
+ * starts its own line, so that's the member's own token. This tracks "the line
+ * I'm on" without guessing between neighbouring keys.
+ *
+ * From the anchor, each ancestor contributes a segment: a `Property` its key, a
+ * value directly inside an `Array` its index. Collected deepest-first, reversed.
+ */
+export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
+ const tree = syntaxTree(state);
+ if (tree.topNode.name !== "JsoncText") return null;
+
+ let node: SyntaxNode = tree.resolveInner(pos, -1);
+ if (CONTAINER_NODES.has(node.name)) {
+ const line = state.doc.lineAt(pos);
+ const indent = line.text.length - line.text.trimStart().length;
+ if (indent < line.text.length) {
+ node = tree.resolveInner(line.from + indent, 1);
+ }
+ }
+
+ const segments: JsonPathSegment[] = [];
+ let cur: SyntaxNode | null = node;
+ while (cur != null) {
+ const parent: SyntaxNode | null = cur.parent;
+ if (cur.name === "Property") {
+ const nameNode = cur.getChild("PropertyName");
+ if (nameNode != null) {
+ segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
+ }
+ } else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
+ segments.push({ kind: "index", index: indexInArray(parent, cur) });
+ }
+ cur = parent;
+ }
+
+ segments.reverse();
+ return segments;
+}
+
+const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
+
+/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
+function segmentToJsonPath(segment: JsonPathSegment): string {
+ if (segment.kind === "index") return `[${segment.index}]`;
+ // jsonpath-plus interprets a bare `$` as its root operator.
+ if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
+ // Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
+ // JSON.stringify gives correct double-quoting and escaping, matching the
+ // convention the JSONPath filter box already displays.
+ return `[${JSON.stringify(segment.key)}]`;
+}
+
+/**
+ * Build a JSONPath expression for the first `count` segments (all of them by
+ * default). `segmentsToJsonPath([])` is the root, `$`.
+ */
+export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
+ return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
+}
diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts
new file mode 100644
index 0000000..6df9f0a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts
@@ -0,0 +1,500 @@
+import { EditorState } from "@codemirror/state";
+import { jsonc } from "@shopify/lang-jsonc";
+import { text } from "./text/extension";
+import { twig } from "./twig/extension";
+import { describe, expect, test } from "vite-plus/test";
+import {
+ COLLAPSE_MEDIA_CHARS,
+ COLLAPSE_TOKEN_CHARS,
+ collapseDecorations,
+ largeValues,
+ MAX_VISIBLE_LINE_CHARS,
+} from "./largeValues";
+import type { SniffedValue } from "./sniffValue";
+
+const BIG = "A".repeat(1_000_000);
+
+/** With a grammar, so tokens can be collapsed individually */
+const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
+
+/** Without a grammar, so only the column rule applies */
+const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
+
+/**
+ * The decorations as if the whole document were on screen.
+ *
+ * In the editor the plugin passes the viewport instead, which is the same call with narrower
+ * ranges — the rules themselves don't know the difference.
+ */
+function decorationsFor(state: EditorState) {
+ return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
+}
+
+function collapsedRanges(state: EditorState) {
+ const ranges: { from: number; to: number }[] = [];
+ const iter = decorationsFor(state).iter();
+ while (iter.value != null) {
+ ranges.push({ from: iter.from, to: iter.to });
+ iter.next();
+ }
+ return ranges;
+}
+
+/** How much of each line is still rendered */
+function visibleLineLengths(state: EditorState) {
+ const hidden = collapsedRanges(state);
+ const lengths: number[] = [];
+ for (let n = 1; n <= state.doc.lines; n++) {
+ const line = state.doc.line(n);
+ const covered = hidden
+ .filter((h) => h.from >= line.from && h.to <= line.to)
+ .reduce((sum, h) => sum + (h.to - h.from), 0);
+ lengths.push(line.length - covered);
+ }
+ return lengths;
+}
+
+describe("collapsing", () => {
+ test("leaves an ordinary body alone", () => {
+ expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
+ });
+
+ test("leaves a large body of short lines alone", () => {
+ const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+ expect(collapsedRanges(jsonState(doc))).toEqual([]);
+ });
+
+ test("leaves a line just under the column limit alone", () => {
+ expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
+ });
+
+ test("never renders more than the column limit per line", () => {
+ for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
+ for (const length of visibleLineLengths(state)) {
+ expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ }
+ }
+ });
+
+ test("keeps the document text intact", () => {
+ const doc = `{"image":"${BIG}"}`;
+ expect(jsonState(doc).sliceDoc()).toBe(doc);
+ expect(jsonState(doc).doc.length).toBe(doc.length);
+ });
+});
+
+describe("token collapsing, with a grammar", () => {
+ test("hides the whole value, leaving its quotes visible", () => {
+ const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
+ const state = jsonState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(1);
+ // Exactly the text between the quotes, so the line reads as `"image":""`
+ 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);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts
new file mode 100644
index 0000000..4918599
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.ts
@@ -0,0 +1,584 @@
+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 =
+ ' ';
+
+/**
+ * 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[] = [];
+ 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")];
diff --git a/apps/yaak-client/components/core/Editor/pairs/extension.ts b/apps/yaak-client/components/core/Editor/pairs/extension.ts
new file mode 100644
index 0000000..823e9b3
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./pairs";
+
+const language = LRLanguage.define({
+ name: "pairs",
+ parser,
+ languageData: {},
+});
+
+export function pairs() {
+ return new LanguageSupport(language, []);
+}
diff --git a/apps/yaak-client/components/core/Editor/pairs/highlight.ts b/apps/yaak-client/components/core/Editor/pairs/highlight.ts
new file mode 100644
index 0000000..954deff
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ Sep: t.bracket,
+ Key: t.attributeName,
+ Value: t.string,
+});
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.grammar b/apps/yaak-client/components/core/Editor/pairs/pairs.grammar
new file mode 100644
index 0000000..bcb1fca
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.grammar
@@ -0,0 +1,9 @@
+@top pairs { (Key Sep Value "\n")* }
+
+@tokens {
+ Sep { ":" $[ \t]+ }
+ Key { ":"? ![:]+ }
+ Value { ![\n]+ }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts
new file mode 100644
index 0000000..279b8f8
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts
@@ -0,0 +1,5 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const pairs = 1,
+ Key = 2,
+ Sep = 3,
+ Value = 4;
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
new file mode 100644
index 0000000..440f7c5
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
@@ -0,0 +1,26 @@
+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");
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.ts
new file mode 100644
index 0000000..fdd40d5
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.ts
@@ -0,0 +1,29 @@
+// 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: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R",
+ stateData: "f~OQPO~ORRO~OSTO~OVUO~O",
+ goto: "]UPPPPPVQQORSQ",
+ nodeNames: "⚠ pairs Key Sep Value",
+ maxTerm: 7,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 1,
+ tokenData:
+ "%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
+ tokenizers: [0, 1, 2],
+ topRules: { pairs: [0, 1] },
+ tokenPrec: 0,
+ termNames: {
+ "0": "⚠",
+ "1": "@top",
+ "2": "Key",
+ "3": "Sep",
+ "4": "Value",
+ "5": '(Key Sep Value "\\n")+',
+ "6": "␄",
+ "7": '"\\n"',
+ },
+});
diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts b/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
new file mode 100644
index 0000000..ad63819
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
@@ -0,0 +1,202 @@
+import { SearchQuery } from "@codemirror/search";
+import { EditorState } from "@codemirror/state";
+import { describe, expect, test } from "vite-plus/test";
+import {
+ currentMatch,
+ literalSearch,
+ MAX_COUNT,
+ MatchCounter,
+ normalizeDoc,
+ normalizeSearch,
+ scanNormalized,
+ scanQuery,
+} from "./searchMatchCount";
+
+type QueryConfig = ConstructorParameters[0];
+
+const stateOf = (doc: string) => EditorState.create({ doc });
+
+/**
+ * The matches the counter finds, having checked them against the search panel's own cursor.
+ *
+ * The cursor decides which ranges the editor highlights and which one `find next` lands on, so
+ * a count that doesn't agree with it is a wrong count, however fast it was to produce.
+ */
+function matchesOf(doc: string, config: QueryConfig) {
+ const state = stateOf(doc);
+ const query = new SearchQuery(config);
+ const matches = new MatchCounter().matches(state, query);
+ expect(matches).toEqual(scanQuery(state, query));
+ return matches;
+}
+
+const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
+
+describe("counting", () => {
+ test("counts every match, whatever the case", () => {
+ expect(countOf("one Two three two", { search: "two" })).toBe(2);
+ expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
+ });
+
+ test("skips matches overlapping an earlier one", () => {
+ expect(countOf("aaaaa", { search: "aa" })).toBe(2);
+ expect(countOf("ababa", { search: "aba" })).toBe(1);
+ });
+
+ test("treats a query as text, not as a pattern", () => {
+ expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
+ });
+
+ test("unquotes escapes unless the query is literal", () => {
+ expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
+ expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
+ });
+
+ test("counts regexp and whole word queries through the cursor", () => {
+ expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
+ expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
+ expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
+ expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
+ });
+
+ test("stops counting at the cap", () => {
+ expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
+ });
+
+ test("reports where the matches are", () => {
+ expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
+ { from: 0, to: 2 },
+ { from: 4, to: 6 },
+ ]);
+ });
+
+ test("finds nothing to match with an empty needle", () => {
+ expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
+ });
+});
+
+describe("normalization", () => {
+ test("finds what a character decomposes into", () => {
+ // The é is one character holding an `e`, and the match covers the whole of it
+ expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
+ expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
+ expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
+ expect(countOf("one two", { search: "one two" })).toBe(1);
+ expect(countOf("full width", { search: "full" })).toBe(1);
+ });
+
+ test("matches a decomposed query against composed text, and the reverse", () => {
+ expect(countOf("café", { search: "café" })).toBe(1);
+ expect(countOf("café", { search: "café" })).toBe(1);
+ expect(countOf("café", { search: "café" })).toBe(1);
+ });
+
+ test("keeps offsets straight after an expansion", () => {
+ expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
+ expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
+ });
+
+ test("normalizes the query whole, the document by character", () => {
+ expect(normalizeSearch("CAFÉ", false)).toBe("café");
+ expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
+ // Whole-string NFKD would fold this to a final sigma, which the cursor never does
+ expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
+ });
+
+ test("leaves a document that normalizes to itself untouched", () => {
+ const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
+ expect(text).toBe("plain 日本 🎉 text");
+ expect(expansions).toEqual([]);
+ });
+});
+
+describe("current match", () => {
+ const matches = [
+ { from: 0, to: 2 },
+ { from: 4, to: 6 },
+ { from: 8, to: 10 },
+ ];
+
+ test("counts from one, and reports 0 off a match", () => {
+ expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
+ expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
+ expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
+ expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
+ expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
+ expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
+ });
+
+ test("moving the selection doesn't scan again", () => {
+ const state = stateOf("a1 b2 c3");
+ const query = new SearchQuery({ search: "\\d", regexp: true });
+ const counter = new MatchCounter();
+ const found = counter.matches(state, query);
+
+ // The document a selection-only transaction leaves behind is the one already scanned
+ const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
+ expect(counter.matches(moved, query)).toBe(found);
+ expect(currentMatch(found, moved.selection.main)).toBe(2);
+ });
+});
+
+/**
+ * The mapping from normalized offsets back to document offsets is the part of this that can go
+ * quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
+ */
+describe("against the cursor, on awkward text", () => {
+ const ALPHABET = [
+ ..."abcABC .\\\n".split(""),
+ "é",
+ "é",
+ "fi",
+ "…",
+ " ",
+ "İ",
+ "Σ",
+ "ς",
+ "日",
+ "🎉",
+ "Ⅻ",
+ "f",
+ "①",
+ "́",
+ ];
+
+ /** Seeded, so a failure is the same failure next run */
+ function random(seed: number) {
+ let state = seed;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 2 ** 32;
+ };
+ }
+
+ for (const caseSensitive of [false, true]) {
+ test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
+ const next = random(caseSensitive ? 20260831 : 7);
+
+ for (let round = 0; round < 400; round++) {
+ const doc = Array.from(
+ { length: 2 + Math.floor(next() * 60) },
+ () => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
+ ).join("");
+
+ // Half the queries are lifted out of the document, so matches are actually found
+ const start = Math.floor(next() * doc.length);
+ const search =
+ next() < 0.5
+ ? doc.slice(start, start + 1 + Math.floor(next() * 3))
+ : Array.from(
+ { length: 1 + Math.floor(next() * 2) },
+ () => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
+ ).join("");
+ if (search === "") continue;
+
+ const state = stateOf(doc);
+ const query = new SearchQuery({ search, caseSensitive });
+ const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
+ expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
+ }
+ });
+ }
+});
diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.ts b/apps/yaak-client/components/core/Editor/searchMatchCount.ts
new file mode 100644
index 0000000..5f05fa2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/searchMatchCount.ts
@@ -0,0 +1,325 @@
+import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
+import type { EditorState, Extension, Text } from "@codemirror/state";
+import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
+
+/** Matches are counted no further than this, since an exact total stops being useful long before */
+export const MAX_COUNT = 9999;
+
+/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
+const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
+const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
+
+export interface Match {
+ from: number;
+ to: number;
+}
+
+/** A character whose normalized form is a different length, shifting every offset past it */
+interface Expansion {
+ normFrom: number;
+ normTo: number;
+ docFrom: number;
+ docTo: number;
+}
+
+/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
+export interface NormalizedDoc {
+ text: string;
+ expansions: Expansion[];
+}
+
+/**
+ * Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
+ * case-sensitive — in a single pass rather than one call per code point.
+ *
+ * The cursor spends 90% of its time asking ICU about one character at a time, which is what
+ * makes counting matches in a large response slow. Doing it a character at a time still matters
+ * for the result, since it keeps NFKD from reordering marks across characters, so the
+ * granularity stays and only the repeated work goes: each distinct character is normalized once
+ * and the answer reused, and ASCII runs never reach ICU at all.
+ */
+export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
+ const rewritten = new Map();
+ const expansions: Expansion[] = [];
+ let shift = 0;
+
+ const normalized = text.replace(
+ caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
+ (chunk: string, at: number) => {
+ // An ASCII run only ever folds case, which can't change its length
+ if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
+
+ let out = rewritten.get(chunk);
+ if (out === undefined) {
+ out = chunk.normalize("NFKD");
+ if (!caseSensitive) out = out.toLowerCase();
+ rewritten.set(chunk, out);
+ }
+
+ if (out.length !== chunk.length) {
+ expansions.push({
+ normFrom: at + shift,
+ normTo: at + shift + out.length,
+ docFrom: at,
+ docTo: at + chunk.length,
+ });
+ shift += out.length - chunk.length;
+ }
+
+ return out;
+ },
+ );
+
+ return { text: normalized, expansions };
+}
+
+/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
+export function normalizeSearch(search: string, caseSensitive: boolean): string {
+ const normalized = search.normalize("NFKD");
+ return caseSensitive ? normalized : normalized.toLowerCase();
+}
+
+/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
+export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
+ const matches: Match[] = [];
+ if (needle === "") return matches;
+
+ const { text } = doc;
+ let pos = text.indexOf(needle);
+ while (pos >= 0) {
+ let end = pos + needle.length;
+ // However the query was cut, a match ends on a whole code point, as the cursor's do
+ if (isLowSurrogate(text.charCodeAt(end))) end++;
+
+ matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
+ if (matches.length > MAX_COUNT) break;
+
+ pos = text.indexOf(needle, resumeAfter(doc, end));
+ }
+
+ return matches;
+}
+
+/** The same through the query's own cursor, which handles regexps and whole words */
+export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
+ const matches: Match[] = [];
+ const cursor = query.getCursor(state);
+
+ for (let result = cursor.next(); !result.done; result = cursor.next()) {
+ matches.push({ from: result.value.from, to: result.value.to });
+ if (matches.length > MAX_COUNT) break;
+ }
+
+ return matches;
+}
+
+const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
+
+/** The last character expansion beginning at or before `offset`, if there is one */
+function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
+ let low = 0;
+ let high = expansions.length - 1;
+ let found: Expansion | null = null;
+
+ while (low <= high) {
+ const mid = (low + high) >> 1;
+ if (expansions[mid]!.normFrom <= offset) {
+ found = expansions[mid]!;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ return found;
+}
+
+function docStart(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ if (expansion == null) return offset;
+ // A match starting inside a character's expansion starts at the character
+ return offset < expansion.normTo
+ ? expansion.docFrom
+ : offset - (expansion.normTo - expansion.docTo);
+}
+
+/**
+ * Where scanning picks up after a match ending at `offset`.
+ *
+ * The cursor moves through the document a character at a time, so once a match ends inside a
+ * character's expansion the rest of that expansion is behind it — "…" holds three dots but only
+ * ever counts as one match of ".".
+ */
+function resumeAfter(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
+ ? expansion.normTo
+ : offset;
+}
+
+function docEnd(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ if (expansion == null) return offset;
+ if (offset <= expansion.normFrom) return expansion.docFrom;
+ // A match ending inside a character's expansion covers the whole character
+ return offset < expansion.normTo
+ ? expansion.docTo
+ : offset - (expansion.normTo - expansion.docTo);
+}
+
+/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
+export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
+ let index = 0;
+ for (const match of matches) {
+ index++;
+ if (match.from <= selection.from && match.to >= selection.to) return index;
+ }
+ return 0;
+}
+
+/** The text a plain query looks for, or null when only the cursor can answer it */
+export function literalSearch(query: SearchQuery): string | null {
+ if (query.regexp || query.wholeWord || query.test != null) return null;
+ // Mirrors SearchQuery's own unquoting, which the published type doesn't expose
+ return query.literal
+ ? query.search
+ : query.search.replace(/\\([nrt\\])/g, (_, ch) =>
+ ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
+ );
+}
+
+/**
+ * Finds the matches for the search panel, keeping the normalized document and the matches it
+ * last found, so neither moving the selection nor typing another character starts over.
+ */
+export class MatchCounter {
+ private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
+ private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
+
+ matches(state: EditorState, query: SearchQuery): Match[] {
+ const last = this.last;
+ if (last != null && last.doc === state.doc && last.query.eq(query)) {
+ return last.matches;
+ }
+
+ const matches = this.scan(state, query);
+ this.last = { doc: state.doc, query, matches };
+ return matches;
+ }
+
+ private scan(state: EditorState, query: SearchQuery): Match[] {
+ const search = literalSearch(query);
+ if (search == null) return scanQuery(state, query);
+
+ const doc = this.normalizedDoc(state.doc, query.caseSensitive);
+ return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
+ }
+
+ private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
+ const cached = this.doc;
+ if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
+ return cached.normalized;
+ }
+
+ const normalized = normalizeDoc(doc.toString(), caseSensitive);
+ this.doc = { doc, caseSensitive, normalized };
+ return normalized;
+ }
+}
+
+/**
+ * A CodeMirror extension that displays the total number of search matches
+ * inside the built-in search panel.
+ */
+export function searchMatchCount(): Extension {
+ return ViewPlugin.fromClass(
+ class {
+ private countEl: HTMLElement | null = null;
+ private counter = new MatchCounter();
+
+ constructor(private view: EditorView) {
+ this.updateCount();
+ }
+
+ update(update: ViewUpdate) {
+ // Recompute when doc changes, search state changes, or selection moves
+ const query = getSearchQuery(update.state);
+ const prevQuery = getSearchQuery(update.startState);
+ const open = searchPanelOpen(update.state);
+ const prevOpen = searchPanelOpen(update.startState);
+
+ if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
+ this.updateCount();
+ }
+ }
+
+ private updateCount() {
+ const state = this.view.state;
+ const open = searchPanelOpen(state);
+ const query = getSearchQuery(state);
+
+ if (!open) {
+ this.removeCountEl();
+ return;
+ }
+
+ this.ensureCountEl();
+ if (this.countEl == null) return;
+
+ if (!query.search) {
+ this.countEl.textContent = "0/0";
+ return;
+ }
+
+ const matches = this.counter.matches(state, query);
+ if (matches.length > MAX_COUNT) {
+ this.countEl.textContent = `${MAX_COUNT}+`;
+ } else if (matches.length === 0) {
+ this.countEl.textContent = "0/0";
+ } else {
+ const current = currentMatch(matches, state.selection.main);
+ this.countEl.textContent = `${current}/${matches.length}`;
+ }
+ }
+
+ private ensureCountEl() {
+ // Find the search panel in the editor DOM
+ const panel = this.view.dom.querySelector(".cm-search");
+ if (!panel) {
+ this.countEl = null;
+ return;
+ }
+
+ if (this.countEl && this.countEl.parentElement === panel) {
+ return; // Already attached
+ }
+
+ this.countEl = document.createElement("span");
+ this.countEl.className = "cm-search-match-count";
+
+ // Reorder: insert prev button, then next button, then count after the search input
+ const searchInput = panel.querySelector("input");
+ const prevBtn = panel.querySelector('button[name="prev"]');
+ const nextBtn = panel.querySelector('button[name="next"]');
+ if (searchInput && searchInput.parentElement === panel) {
+ searchInput.after(this.countEl);
+ if (prevBtn) this.countEl.after(prevBtn);
+ if (nextBtn && prevBtn) prevBtn.after(nextBtn);
+ } else {
+ panel.prepend(this.countEl);
+ }
+ }
+
+ private removeCountEl() {
+ if (this.countEl) {
+ this.countEl.remove();
+ this.countEl = null;
+ }
+ }
+
+ destroy() {
+ this.removeCountEl();
+ }
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/singleLine.ts b/apps/yaak-client/components/core/Editor/singleLine.ts
new file mode 100644
index 0000000..20c4b14
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/singleLine.ts
@@ -0,0 +1,47 @@
+import type { Extension, TransactionSpec } from "@codemirror/state";
+import { EditorSelection, EditorState, Transaction } from "@codemirror/state";
+
+/**
+ * A CodeMirror extension that forces single-line input by stripping
+ * all newline characters from user input, including pasted content.
+ *
+ * This extension uses a transaction filter to intercept user input,
+ * removes any newline characters, and adjusts the selection to the end
+ * of the inserted text.
+ *
+ * IME composition events are ignored to preserve proper input behavior
+ * for non-Latin languages.
+ *
+ * @returns A CodeMirror extension that enforces single-line editing.
+ */
+export function singleLineExtensions(): Extension {
+ return EditorState.transactionFilter.of(
+ (tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
+ if (!tr.isUserEvent("input") || tr.isUserEvent("input.type.compose")) return tr;
+
+ const changes: { from: number; to: number; insert: string }[] = [];
+
+ tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {
+ let insert = "";
+ for (const line of inserted.iterLines()) {
+ insert += line.replace(/\n/g, "");
+ }
+
+ if (insert !== inserted.toString()) {
+ changes.push({ from: fromB, to: toA, insert });
+ }
+ });
+
+ const lastChange = changes[changes.length - 1];
+ if (lastChange == null) return tr;
+
+ const selection = EditorSelection.cursor(lastChange.from + lastChange.insert.length);
+
+ return {
+ changes,
+ selection,
+ userEvent: tr.annotation(Transaction.userEvent) ?? undefined,
+ };
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/sniffValue.test.ts b/apps/yaak-client/components/core/Editor/sniffValue.test.ts
new file mode 100644
index 0000000..0e55936
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/sniffValue.test.ts
@@ -0,0 +1,170 @@
+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(" ")}`;
+ 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);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/sniffValue.ts b/apps/yaak-client/components/core/Editor/sniffValue.ts
new file mode 100644
index 0000000..6a23a6a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/sniffValue.ts
@@ -0,0 +1,219 @@
+/**
+ * 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:[][; …],` — 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 = {
+ 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 = {
+ "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();
+}
diff --git a/apps/yaak-client/components/core/Editor/text/extension.ts b/apps/yaak-client/components/core/Editor/text/extension.ts
new file mode 100644
index 0000000..a398f2a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./text";
+
+export const textLanguage = LRLanguage.define({
+ name: "text",
+ parser,
+ languageData: {},
+});
+
+export function text() {
+ return new LanguageSupport(textLanguage);
+}
diff --git a/apps/yaak-client/components/core/Editor/text/text.grammar b/apps/yaak-client/components/core/Editor/text/text.grammar
new file mode 100644
index 0000000..57dc055
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.grammar
@@ -0,0 +1,5 @@
+@top Template { Text }
+
+@tokens {
+ Text { ![]+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/text/text.terms.ts b/apps/yaak-client/components/core/Editor/text/text.terms.ts
new file mode 100644
index 0000000..ac3b3af
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.terms.ts
@@ -0,0 +1,3 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Template = 1,
+ Text = 2;
diff --git a/apps/yaak-client/components/core/Editor/text/text.ts b/apps/yaak-client/components/core/Editor/text/text.ts
new file mode 100644
index 0000000..b09b79c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.ts
@@ -0,0 +1,16 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import { LRParser } from "@lezer/lr";
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "[OQOPOOQOOOOO",
+ stateData: "V~OQPO~O",
+ goto: "QPP",
+ nodeNames: "⚠ Template Text",
+ maxTerm: 3,
+ skippedNodes: [0],
+ repeatNodeCount: 0,
+ tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
+ tokenizers: [0],
+ topRules: { Template: [0, 1] },
+ tokenPrec: 0,
+});
diff --git a/apps/yaak-client/components/core/Editor/timeline/extension.ts b/apps/yaak-client/components/core/Editor/timeline/extension.ts
new file mode 100644
index 0000000..b1a0041
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./timeline";
+
+export const timelineLanguage = LRLanguage.define({
+ name: "timeline",
+ parser,
+ languageData: {},
+});
+
+export function timeline() {
+ return new LanguageSupport(timelineLanguage);
+}
diff --git a/apps/yaak-client/components/core/Editor/timeline/highlight.ts b/apps/yaak-client/components/core/Editor/timeline/highlight.ts
new file mode 100644
index 0000000..6f8c9e9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
+ IncomingText: t.tagName, // < lines - info color (matches timeline icons)
+ InfoText: t.comment, // * lines - subtle color (matches timeline icons)
+});
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.grammar b/apps/yaak-client/components/core/Editor/timeline/timeline.grammar
new file mode 100644
index 0000000..f8def8d
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.grammar
@@ -0,0 +1,21 @@
+@top Timeline { line* }
+
+line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
+
+@skip {} {
+ OutgoingLine { OutgoingText Newline }
+ IncomingLine { IncomingText Newline }
+ InfoLine { InfoText Newline }
+ PlainLine { PlainText Newline }
+}
+
+@tokens {
+ OutgoingText { "> " ![\n]* }
+ IncomingText { "< " ![\n]* }
+ InfoText { "* " ![\n]* }
+ PlainText { ![\n]+ }
+ Newline { "\n" }
+ @precedence { OutgoingText, IncomingText, InfoText, PlainText }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts b/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts
new file mode 100644
index 0000000..8f03d42
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts
@@ -0,0 +1,11 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Timeline = 1,
+ OutgoingLine = 2,
+ OutgoingText = 3,
+ Newline = 4,
+ IncomingLine = 5,
+ IncomingText = 6,
+ InfoLine = 7,
+ InfoText = 8,
+ PlainLine = 9,
+ PlainText = 10;
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.ts b/apps/yaak-client/components/core/Editor/timeline/timeline.ts
new file mode 100644
index 0000000..fee8020
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.ts
@@ -0,0 +1,21 @@
+// 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:
+ "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
+ stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
+ goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
+ nodeNames:
+ "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
+ maxTerm: 13,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 1,
+ tokenData:
+ "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
+ tokenizers: [0],
+ topRules: { Timeline: [0, 1] },
+ tokenPrec: 36,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/completion.test.ts b/apps/yaak-client/components/core/Editor/twig/completion.test.ts
new file mode 100644
index 0000000..ff8fd94
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/completion.test.ts
@@ -0,0 +1,155 @@
+/* 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 { singleLineExtensions } from "../singleLine";
+import { twigCompletion, type TwigCompletionOption } from "./completion";
+import { parser } from "./twig";
+
+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);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/completion.ts b/apps/yaak-client/components/core/Editor/twig/completion.ts
new file mode 100644
index 0000000..ae0a979
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/completion.ts
@@ -0,0 +1,156 @@
+import type { Completion, CompletionContext } from "@codemirror/autocomplete";
+import { startCompletion } from "@codemirror/autocomplete";
+import type { EditorState } from "@codemirror/state";
+import type { TemplateFunction } from "@yaakapp-internal/plugins";
+
+const openTag = "${[ ";
+const closeTag = " ]}";
+
+export type TwigCompletionOptionVariable = {
+ type: "variable";
+};
+
+export type TwigCompletionOptionNamespace = {
+ type: "namespace";
+};
+
+export type TwigCompletionOptionFunction = TemplateFunction & {
+ type: "function";
+};
+
+export type TwigCompletionOption = (
+ | TwigCompletionOptionFunction
+ | TwigCompletionOptionVariable
+ | TwigCompletionOptionNamespace
+) & {
+ name: string;
+ label: string | HTMLElement;
+ description?: string;
+ onClick: (rawTag: string, startPos: number) => void;
+ value: string | null;
+ invalid?: boolean;
+};
+
+export interface TwigCompletionConfig {
+ options: TwigCompletionOption[];
+}
+
+const MIN_MATCH_NAME = 1;
+
+// Only inspect adjacent whitespace, including newlines, rather than copying the
+// entire document to look for a delimiter in large request bodies.
+function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
+ while (direction === -1 ? pos > 0 : pos < state.doc.length) {
+ const from = direction === -1 ? pos - 1 : pos;
+ if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
+ pos += direction;
+ }
+ return pos;
+}
+
+function findOpenTag(state: EditorState, from: number) {
+ const end = skipWhitespace(state, from, -1);
+ return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
+}
+
+export function twigCompletion({ options }: TwigCompletionConfig) {
+ return function completions(context: CompletionContext) {
+ const toStartOfName = context.matchBefore(/[\w_.]*/);
+ const toMatch = toStartOfName ?? null;
+
+ if (toMatch === null) return null;
+
+ const matchLen = toMatch.to - toMatch.from;
+ const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
+ if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
+ return null;
+ }
+
+ const completions: Completion[] = options
+ .flatMap((o): Completion[] => {
+ const matchSegments = toMatch.text.replace(/^\$/, "").split(".");
+ const optionSegments = o.name.split(".");
+
+ // If not on the last segment, only complete the namespace
+ if (matchSegments.length < optionSegments.length) {
+ const prefix = optionSegments.slice(0, matchSegments.length).join(".");
+ return [
+ {
+ label: `${prefix}.*`,
+ type: "namespace",
+ detail: "namespace",
+ apply: (view, _completion, from, to) => {
+ const insert = `${prefix}.`;
+ view.dispatch({
+ changes: { from, to, insert: insert },
+ selection: { anchor: from + insert.length },
+ });
+ // Leave the autocomplete open so the user can continue typing the rest of the namespace
+ startCompletion(view);
+ },
+ },
+ ];
+ }
+
+ // If on the last segment, wrap the entire tag
+ const inner = o.type === "function" ? `${o.name}()` : o.name;
+ return [
+ {
+ label: o.name,
+ info: o.description,
+ detail: o.type,
+ type: o.type === "variable" ? "variable" : "function",
+ apply: (view, _completion, from, to) => {
+ // Keep the completion range name-only for filtering, but replace a manually
+ // typed opener when accepting (through either the keyboard or the menu).
+ const opener = findOpenTag(view.state, from);
+ if (opener !== null) {
+ from = opener;
+ const hasParentheses =
+ o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
+ const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
+ if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
+ }
+ const insert = openTag + inner + closeTag;
+ view.dispatch({
+ changes: { from, to, insert: insert },
+ selection: { anchor: from + insert.length },
+ });
+ },
+ },
+ ];
+ })
+ .filter((v) => v != null);
+
+ const uniqueCompletions = uniqueBy(completions, "label");
+ const sortedCompletions = uniqueCompletions.sort((a, b) => {
+ const boostDiff = defaultBoost(b) - defaultBoost(a);
+ if (boostDiff !== 0) return boostDiff;
+ return a.label.localeCompare(b.label);
+ });
+
+ return {
+ matchLen,
+ // Delimiters change the replacement range; dots change namespace options.
+ validFor: /^\w*$/,
+ from: toMatch.from,
+ options: sortedCompletions,
+ };
+ };
+}
+
+export function uniqueBy(arr: T[], key: K): T[] {
+ const map = new Map();
+ for (const item of arr) {
+ map.set(item[key], item); // overwrites → keeps last
+ }
+ return [...map.values()];
+}
+
+export function defaultBoost(o: Completion) {
+ if (o.type === "variable") return 4;
+ if (o.type === "constant") return 3;
+ if (o.type === "function") return 2;
+ if (o.type === "namespace") return 1;
+ return 0;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/extension.ts b/apps/yaak-client/components/core/Editor/twig/extension.ts
new file mode 100644
index 0000000..bbd4c8c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/extension.ts
@@ -0,0 +1,85 @@
+import type { LanguageSupport } from "@codemirror/language";
+import { LRLanguage } from "@codemirror/language";
+import type { Extension } from "@codemirror/state";
+import { parseMixed } from "@lezer/common";
+import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
+import type { GenericCompletionConfig } from "../genericCompletion";
+import { genericCompletion } from "../genericCompletion";
+import { textLanguage } from "../text/extension";
+import type { TwigCompletionOption } from "./completion";
+import { twigCompletion } from "./completion";
+import { templateTagsPlugin } from "./templateTags";
+import { parser as twigParser } from "./twig";
+
+export function twig({
+ base,
+ environmentVariables,
+ completionOptions,
+ autocomplete,
+ onClickVariable,
+ onClickMissingVariable,
+ extraExtensions,
+}: {
+ base: LanguageSupport;
+ environmentVariables: WrappedEnvironmentVariable[];
+ completionOptions: TwigCompletionOption[];
+ autocomplete?: GenericCompletionConfig;
+ onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
+ onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
+ onClickPathParameter: (name: string) => void;
+ extraExtensions: Extension[];
+}) {
+ const language = mixLanguage(base);
+
+ const variableOptions: TwigCompletionOption[] =
+ environmentVariables.map((v) => ({
+ name: v.variable.name,
+ value: v.variable.value,
+ type: "variable",
+ label: v.variable.name,
+ description: `Inherited from ${v.source}`,
+ onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
+ })) ?? [];
+
+ const options = [...variableOptions, ...completionOptions];
+ const completions = twigCompletion({ options });
+
+ return [
+ language,
+ base.support,
+ language.data.of({ autocomplete: completions }),
+ base.language.data.of({ autocomplete: completions }),
+ language.data.of({ autocomplete: genericCompletion(autocomplete) }),
+ base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
+ templateTagsPlugin(options, onClickMissingVariable),
+ ...extraExtensions,
+ ];
+}
+
+const mixedLanguagesCache: Record = {};
+
+function mixLanguage(base: LanguageSupport): LRLanguage {
+ // It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up
+ const cached = mixedLanguagesCache[base.language.name];
+ if (cached != null) {
+ return cached;
+ }
+
+ const parser = twigParser.configure({
+ wrap: parseMixed((node) => {
+ // If the base language is text, we can overwrite at the top
+ if (base.language.name !== textLanguage.name && !node.type.isTop) {
+ return null;
+ }
+
+ return {
+ parser: base.language.parser,
+ overlay: (node) => node.type.name === "Text",
+ };
+ }),
+ });
+
+ const language = LRLanguage.define({ name: "twig", parser });
+ mixedLanguagesCache[base.language.name] = language;
+ return language;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/highlight.ts b/apps/yaak-client/components/core/Editor/twig/highlight.ts
new file mode 100644
index 0000000..d3a614e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ TagOpen: t.bracket,
+ TagClose: t.bracket,
+ TagContent: t.keyword,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts
new file mode 100644
index 0000000..2f76dbd
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts
@@ -0,0 +1,106 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Range } from "@codemirror/state";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+
+class PathPlaceholderWidget extends WidgetType {
+ readonly #clickListenerCallback: () => void;
+
+ constructor(
+ readonly rawText: string,
+ readonly startPos: number,
+ readonly onClick: () => void,
+ ) {
+ super();
+ this.#clickListenerCallback = () => {
+ this.onClick?.();
+ };
+ }
+
+ eq(other: PathPlaceholderWidget) {
+ return this.startPos === other.startPos && this.rawText === other.rawText;
+ }
+
+ toDOM() {
+ const elt = document.createElement("span");
+ elt.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
+ elt.textContent = this.rawText;
+ elt.addEventListener("click", this.#clickListenerCallback);
+ return elt;
+ }
+
+ destroy(dom: HTMLElement) {
+ dom.removeEventListener("click", this.#clickListenerCallback);
+ super.destroy(dom);
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+function pathParameters(
+ view: EditorView,
+ onClickPathParameter: (name: string) => void,
+): DecorationSet {
+ const widgets: Range[] = [];
+ const tree = syntaxTree(view.state);
+ for (const { from, to } of view.visibleRanges) {
+ tree.iterate({
+ from,
+ to,
+ enter(node) {
+ if (node.name === "Text") {
+ // Find the `url` node and then jump into it to find the placeholders
+ for (let i = node.from; i < node.to; i++) {
+ const innerTree = tree.resolveInner(i);
+ if (innerTree.node.name === "url") {
+ innerTree.node.cursor().iterate((node) => {
+ if (node.name !== "Placeholder") return;
+ const globalFrom = node.from;
+ const globalTo = node.to;
+ const rawText = view.state.doc.sliceString(globalFrom, globalTo);
+ const onClick = () => onClickPathParameter(rawText);
+ const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
+ const deco = Decoration.replace({ widget, inclusive: false });
+ widgets.push(deco.range(globalFrom, globalTo));
+ });
+ break;
+ }
+ }
+ }
+ },
+ });
+ }
+
+ // Widgets must be sorted start to end
+ widgets.sort((a, b) => a.from - b.from);
+
+ return Decoration.set(widgets);
+}
+
+export function pathParametersPlugin(onClickPathParameter: (name: string) => void) {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = pathParameters(view, onClickPathParameter);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = pathParameters(update.view, onClickPathParameter);
+ }
+ },
+ {
+ decorations(v) {
+ return v.decorations;
+ },
+ provide(plugin) {
+ return EditorView.atomicRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ });
+ },
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/templateTags.ts b/apps/yaak-client/components/core/Editor/twig/templateTags.ts
new file mode 100644
index 0000000..4ca4511
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/templateTags.ts
@@ -0,0 +1,224 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Range } from "@codemirror/state";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+import type { SyntaxNodeRef } from "@lezer/common";
+import { applyFormInputDefaults, validateTemplateFunctionArgs } from "@yaakapp-internal/lib";
+import type { FormInput, JsonPrimitive, TemplateFunction } from "@yaakapp-internal/plugins";
+import { parseTemplate } from "@yaakapp-internal/templates";
+import type { TwigCompletionOption } from "./completion";
+import { collectArgumentValues } from "./util";
+
+class TemplateTagWidget extends WidgetType {
+ readonly #clickListenerCallback: () => void;
+
+ constructor(
+ readonly option: TwigCompletionOption,
+ readonly rawTag: string,
+ readonly startPos: number,
+ ) {
+ super();
+ this.#clickListenerCallback = () => {
+ this.option.onClick?.(this.rawTag, this.startPos);
+ };
+ }
+
+ eq(other: TemplateTagWidget) {
+ return (
+ this.option.name === other.option.name &&
+ this.option.type === other.option.type &&
+ this.option.value === other.option.value &&
+ this.rawTag === other.rawTag &&
+ this.startPos === other.startPos
+ );
+ }
+
+ toDOM() {
+ const elt = document.createElement("span");
+ elt.className = `x-theme-templateTag template-tag ${
+ this.option.invalid
+ ? "x-theme-templateTag--danger"
+ : this.option.type === "variable"
+ ? "x-theme-templateTag--primary"
+ : "x-theme-templateTag--info"
+ }`;
+ elt.title = this.option.invalid ? "Not Found" : (this.option.value ?? "");
+ elt.setAttribute("data-tag-type", this.option.type);
+ if (typeof this.option.label === "string") elt.textContent = this.option.label;
+ else elt.appendChild(this.option.label);
+ elt.addEventListener("click", this.#clickListenerCallback);
+ return elt;
+ }
+
+ destroy(dom: HTMLElement) {
+ dom.removeEventListener("click", this.#clickListenerCallback);
+ super.destroy(dom);
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+function templateTags(
+ view: EditorView,
+ options: TwigCompletionOption[],
+ onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
+): DecorationSet {
+ const widgets: Range[] = [];
+ const tree = syntaxTree(view.state);
+ for (const { from, to } of view.visibleRanges) {
+ tree.iterate({
+ from,
+ to,
+ enter(node) {
+ if (node.name === "Tag") {
+ // Don't decorate if the cursor is inside the match
+ if (isSelectionInsideNode(view, node)) return;
+
+ const rawTag = view.state.doc.sliceString(node.from, node.to);
+
+ // TODO: Search `node.tree` instead of using Regex here
+ const inner = rawTag.replace(/^\$\{\[\s*/, "").replace(/\s*]}$/, "");
+ let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
+
+ if (inner.includes("\n")) {
+ return;
+ }
+
+ // The beta named the function `Response` but was changed in stable.
+ // Keep this here for a while because there's no easy way to migrate
+ if (name === "Response") {
+ name = "response";
+ }
+
+ let option = options.find(
+ (o) => o.name === name || (o.type === "function" && o.aliases?.includes(name)),
+ );
+
+ if (option == null) {
+ const from = node.from; // Cache here so the reference doesn't change
+ option = {
+ type: "variable",
+ invalid: true,
+ name: inner,
+ value: null,
+ label: inner,
+ onClick: () => {
+ onClickMissingVariable(name, rawTag, from);
+ },
+ };
+ }
+
+ if (option.type === "function") {
+ const tokens = parseTemplate(rawTag);
+ const rawValues = collectArgumentValues(tokens, option);
+ const values = applyFormInputDefaults(option.args, rawValues);
+ const label = makeFunctionLabel(option, values);
+ const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
+ option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
+ }
+
+ const widget = new TemplateTagWidget(option, rawTag, node.from);
+ const deco = Decoration.replace({ widget, inclusive: true });
+ widgets.push(deco.range(node.from, node.to));
+ }
+ },
+ });
+ }
+
+ // Widgets must be sorted start to end
+ widgets.sort((a, b) => a.from - b.from);
+
+ return Decoration.set(widgets);
+}
+
+export function templateTagsPlugin(
+ options: TwigCompletionOption[],
+ onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
+) {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = templateTags(view, options, onClickMissingVariable);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = templateTags(update.view, options, onClickMissingVariable);
+ }
+ },
+ {
+ decorations(v) {
+ return v.decorations;
+ },
+ provide(plugin) {
+ return EditorView.atomicRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ });
+ },
+ },
+ );
+}
+
+function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
+ for (const r of view.state.selection.ranges) {
+ if (r.from > node.from && r.to < node.to) return true;
+ }
+ return false;
+}
+
+function makeFunctionLabel(
+ fn: TemplateFunction,
+ values: { [p: string]: JsonPrimitive | undefined },
+): HTMLElement | string {
+ if (fn.args.length === 0) return fn.name;
+
+ const $outer = document.createElement("span");
+ $outer.className = "fn";
+ const $bOpen = document.createElement("span");
+ $bOpen.className = "fn-bracket";
+ $bOpen.textContent = "(";
+ $outer.appendChild(document.createTextNode(fn.name));
+ $outer.appendChild($bOpen);
+
+ const $inner = document.createElement("span");
+ $inner.className = "fn-inner";
+ $inner.title = "";
+ fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
+ const v = String(values[name] || "");
+ if (!v) return;
+ if (all.length > 1) {
+ const $c = document.createElement("span");
+ $c.className = "fn-arg-name";
+ $c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
+ $inner.appendChild($c);
+ }
+
+ const $v = document.createElement("span");
+ $v.className = "fn-arg-value";
+ $v.textContent = v.includes(" ") ? `'${v}'` : v;
+ $inner.appendChild($v);
+ });
+ fn.args.forEach((a: FormInput, i: number) => {
+ if (!("name" in a)) return;
+ const v = values[a.name];
+ if (v == null) return;
+ if (i > 0) $inner.title += "\n";
+ $inner.title += `${a.name} = ${JSON.stringify(v)}`;
+ });
+
+ if ($inner.childNodes.length === 0) {
+ $inner.appendChild(document.createTextNode("…"));
+ }
+
+ $outer.appendChild($inner);
+
+ const $bClose = document.createElement("span");
+ $bClose.className = "fn-bracket";
+ $bClose.textContent = ")";
+ $outer.appendChild($bClose);
+
+ return $outer;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.grammar b/apps/yaak-client/components/core/Editor/twig/twig.grammar
new file mode 100644
index 0000000..6ca447c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.grammar
@@ -0,0 +1,17 @@
+@top Template { (Tag | Text)* }
+
+@local tokens {
+ TagClose { "]}" }
+ @else TagContent
+}
+
+@skip { } {
+ TagOpen { "${[" }
+ Tag { TagOpen (TagContent)+ TagClose }
+}
+
+@tokens {
+ Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.terms.ts b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts
new file mode 100644
index 0000000..7ac3c79
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts
@@ -0,0 +1,7 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Template = 1,
+ Tag = 2,
+ TagOpen = 3,
+ TagContent = 4,
+ TagClose = 5,
+ Text = 6;
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.test.ts b/apps/yaak-client/components/core/Editor/twig/twig.test.ts
new file mode 100644
index 0000000..2ce95f0
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.test.ts
@@ -0,0 +1,108 @@
+/* oxlint-disable no-template-curly-in-string */
+
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./twig";
+
+function getNodeNames(input: string): string[] {
+ const tree = parser.parse(input);
+ const nodes: string[] = [];
+ const cursor = tree.cursor();
+ do {
+ if (cursor.name !== "Template") {
+ nodes.push(cursor.name);
+ }
+ } while (cursor.next());
+ return nodes;
+}
+
+function hasTag(input: string): boolean {
+ return getNodeNames(input).includes("Tag");
+}
+
+function hasError(input: string): boolean {
+ return getNodeNames(input).includes("⚠");
+}
+
+describe("twig grammar", () => {
+ describe("${[var]} format (valid template tags)", () => {
+ test("parses simple variable as Tag", () => {
+ expect(hasTag("${[var]}")).toBe(true);
+ expect(hasError("${[var]}")).toBe(false);
+ });
+
+ test("parses variable with whitespace as Tag", () => {
+ expect(hasTag("${[ var ]}")).toBe(true);
+ expect(hasError("${[ var ]}")).toBe(false);
+ });
+
+ test("parses embedded variable as Tag", () => {
+ expect(hasTag("hello ${[name]} world")).toBe(true);
+ expect(hasError("hello ${[name]} world")).toBe(false);
+ });
+
+ test("parses function call as Tag", () => {
+ expect(hasTag("${[fn()]}")).toBe(true);
+ expect(hasError("${[fn()]}")).toBe(false);
+ });
+ });
+
+ describe("${var} format (should be plain text, not tags)", () => {
+ test("parses ${var} as plain Text without errors", () => {
+ expect(hasTag("${var}")).toBe(false);
+ expect(hasError("${var}")).toBe(false);
+ });
+
+ test("parses embedded ${var} as plain Text", () => {
+ expect(hasTag("hello ${name} world")).toBe(false);
+ expect(hasError("hello ${name} world")).toBe(false);
+ });
+
+ test("parses JSON with ${var} as plain Text", () => {
+ const json = '{"key": "${value}"}';
+ expect(hasTag(json)).toBe(false);
+ expect(hasError(json)).toBe(false);
+ });
+
+ test("parses multiple ${var} as plain Text", () => {
+ expect(hasTag("${a} and ${b}")).toBe(false);
+ expect(hasError("${a} and ${b}")).toBe(false);
+ });
+ });
+
+ describe("mixed content", () => {
+ test("distinguishes ${var} from ${[var]} in same string", () => {
+ const input = "${plain} and ${[tag]}";
+ expect(hasTag(input)).toBe(true);
+ expect(hasError(input)).toBe(false);
+ });
+
+ test("parses JSON with ${[var]} as having Tag", () => {
+ const json = '{"key": "${[value]}"}';
+ expect(hasTag(json)).toBe(true);
+ expect(hasError(json)).toBe(false);
+ });
+ });
+
+ describe("edge cases", () => {
+ test("handles $ at end of string", () => {
+ expect(hasError("hello$")).toBe(false);
+ expect(hasTag("hello$")).toBe(false);
+ });
+
+ test("handles ${ at end of string without crash", () => {
+ // Incomplete syntax may produce errors, but should not crash
+ expect(() => parser.parse("hello${")).not.toThrow();
+ });
+
+ test("handles ${[ without closing without crash", () => {
+ // Unclosed tag may produce partial match, but should not crash
+ expect(() => parser.parse("${[unclosed")).not.toThrow();
+ });
+
+ test("handles empty ${[]}", () => {
+ // Empty tags may or may not be valid depending on grammar
+ // Just ensure no crash
+ expect(() => parser.parse("${[]}")).not.toThrow();
+ });
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.ts b/apps/yaak-client/components/core/Editor/twig/twig.ts
new file mode 100644
index 0000000..78af862
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.ts
@@ -0,0 +1,20 @@
+// 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'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
+ stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
+ goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
+ nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
+ maxTerm: 10,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 2,
+ tokenData:
+ "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~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~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
+ tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
+ topRules: { Template: [0, 1] },
+ tokenPrec: 0,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/util.ts b/apps/yaak-client/components/core/Editor/twig/util.ts
new file mode 100644
index 0000000..330ea85
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/util.ts
@@ -0,0 +1,37 @@
+import type { FormInput, TemplateFunction } from "@yaakapp-internal/plugins";
+import type { Tokens } from "@yaakapp-internal/templates";
+
+/**
+ * Process the initial tokens from the template and merge those with the default values pulled from
+ * the template function definition.
+ */
+export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
+ const initial: Record = {};
+ const initialArgs =
+ initialTokens.tokens[0]?.type === "tag" && initialTokens.tokens[0]?.val.type === "fn"
+ ? initialTokens.tokens[0]?.val.args
+ : [];
+
+ const processArg = (arg: FormInput) => {
+ if ("inputs" in arg && arg.inputs) {
+ arg.inputs.forEach(processArg);
+ }
+ if (!("name" in arg)) return;
+
+ const initialArg = initialArgs.find((a) => a.name === arg.name);
+ const initialArgValue =
+ initialArg?.value.type === "str"
+ ? initialArg?.value.text
+ : initialArg?.value.type === "bool"
+ ? initialArg.value.value
+ : undefined;
+ const value = initialArgValue ?? arg.defaultValue;
+ if (value != null) {
+ initial[arg.name] = value;
+ }
+ };
+
+ templateFunction.args.forEach(processArg);
+
+ return initial;
+}
diff --git a/apps/yaak-client/components/core/Editor/url/completion.test.ts b/apps/yaak-client/components/core/Editor/url/completion.test.ts
new file mode 100644
index 0000000..57e403a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/completion.test.ts
@@ -0,0 +1,49 @@
+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();
+}
diff --git a/apps/yaak-client/components/core/Editor/url/completion.ts b/apps/yaak-client/components/core/Editor/url/completion.ts
new file mode 100644
index 0000000..4a7c9a2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/completion.ts
@@ -0,0 +1,51 @@
+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((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));
diff --git a/apps/yaak-client/components/core/Editor/url/extension.ts b/apps/yaak-client/components/core/Editor/url/extension.ts
new file mode 100644
index 0000000..e900b82
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./url";
+
+const urlLanguage = LRLanguage.define({
+ name: "url",
+ parser,
+ languageData: {},
+});
+
+export function url() {
+ return new LanguageSupport(urlLanguage, []);
+}
diff --git a/apps/yaak-client/components/core/Editor/url/highlight.ts b/apps/yaak-client/components/core/Editor/url/highlight.ts
new file mode 100644
index 0000000..69c8ac9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/highlight.ts
@@ -0,0 +1,10 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ Protocol: t.comment,
+ Placeholder: t.emphasis,
+ // PathSegment: t.tagName,
+ // Host: t.variableName,
+ // Path: t.bool,
+ // Query: t.string,
+});
diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar
new file mode 100644
index 0000000..5e9c80d
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.grammar
@@ -0,0 +1,24 @@
+// 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"
diff --git a/apps/yaak-client/components/core/Editor/url/url.terms.ts b/apps/yaak-client/components/core/Editor/url/url.terms.ts
new file mode 100644
index 0000000..3af7569
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.terms.ts
@@ -0,0 +1,9 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const
+ url = 1,
+ Protocol = 2,
+ Host = 3,
+ Path = 4,
+ PathSegment = 5,
+ Placeholder = 6,
+ Query = 7
diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts
new file mode 100644
index 0000000..8585d9a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.test.ts
@@ -0,0 +1,52 @@
+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",
+ ]);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts
new file mode 100644
index 0000000..359d4de
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.ts
@@ -0,0 +1,18 @@
+// 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
+})
diff --git a/apps/yaak-client/components/core/EventViewer.tsx b/apps/yaak-client/components/core/EventViewer.tsx
new file mode 100644
index 0000000..77ecfc9
--- /dev/null
+++ b/apps/yaak-client/components/core/EventViewer.tsx
@@ -0,0 +1,312 @@
+import type { Virtualizer } from "@tanstack/react-virtual";
+import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { format } from "date-fns";
+import type { ReactNode } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useEventViewerKeyboard } from "../../hooks/useEventViewerKeyboard";
+import { CopyIconButton } from "../CopyIconButton";
+import { AutoScroller } from "./AutoScroller";
+import { Button } from "./Button";
+import { IconButton } from "./IconButton";
+import type { SelectProps } from "./Select";
+import { Select } from "./Select";
+import { Separator } from "./Separator";
+
+interface EventViewerProps {
+ /** Array of events to display */
+ events: T[];
+
+ /** Get unique key for each event */
+ getEventKey: (event: T, index: number) => string;
+
+ /** Render the event row - receives event, index, isActive, and onClick */
+ renderRow: (props: {
+ event: T;
+ index: number;
+ isActive: boolean;
+ onClick: () => void;
+ }) => ReactNode;
+
+ /** Render the detail pane for the selected event */
+ renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
+
+ /** Optional header above the event list (e.g., connection status) */
+ header?: ReactNode;
+
+ /** Error message to display as a banner */
+ error?: string | null;
+
+ /** Key for SplitLayout state persistence */
+ splitLayoutStorageKey: string;
+
+ /** Default ratio for the split (0.0 - 1.0) */
+ defaultRatio?: number;
+
+ /** Enable keyboard navigation (arrow keys) */
+ enableKeyboardNav?: boolean;
+
+ /** Loading state */
+ isLoading?: boolean;
+
+ /** Message to show while loading */
+ loadingMessage?: string;
+
+ /** Message to show when no events */
+ emptyMessage?: string;
+
+ /** Callback when active index changes (for controlled state in parent) */
+ onActiveIndexChange?: (index: number | null) => void;
+}
+
+export function EventViewer({
+ events,
+ getEventKey,
+ renderRow,
+ renderDetail,
+ header,
+ error,
+ splitLayoutStorageKey,
+ defaultRatio = 0.4,
+ enableKeyboardNav = true,
+ isLoading = false,
+ loadingMessage = "Loading events...",
+ emptyMessage = "No events recorded",
+ onActiveIndexChange,
+}: EventViewerProps) {
+ const [activeIndex, setActiveIndexInternal] = useState(null);
+ const [isPanelOpen, setIsPanelOpen] = useState(false);
+
+ // Wrap setActiveIndex to notify parent
+ const setActiveIndex = useCallback(
+ (indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
+ setActiveIndexInternal((prev) => {
+ const newIndex =
+ typeof indexOrUpdater === "function" ? indexOrUpdater(prev) : indexOrUpdater;
+ onActiveIndexChange?.(newIndex);
+ return newIndex;
+ });
+ },
+ [onActiveIndexChange],
+ );
+ const containerRef = useRef(null);
+ const virtualizerRef = useRef | null>(null);
+
+ const activeEvent = useMemo(
+ () => (activeIndex != null ? events[activeIndex] : null),
+ [activeIndex, events],
+ );
+
+ // Check if the event list container is focused
+ const isContainerFocused = useCallback(() => {
+ return containerRef.current?.contains(document.activeElement) ?? false;
+ }, []);
+
+ // Keyboard navigation
+ useEventViewerKeyboard({
+ totalCount: events.length,
+ activeIndex,
+ setActiveIndex,
+ virtualizer: virtualizerRef.current,
+ isContainerFocused,
+ enabled: enableKeyboardNav,
+ closePanel: () => setIsPanelOpen(false),
+ openPanel: () => setIsPanelOpen(true),
+ });
+
+ // Handle virtualizer ready callback
+ const handleVirtualizerReady = useCallback(
+ (virtualizer: Virtualizer) => {
+ virtualizerRef.current = virtualizer;
+ },
+ [],
+ );
+
+ // Handle row click - select and open panel, scroll into view
+ const handleRowClick = useCallback(
+ (index: number) => {
+ setActiveIndex(index);
+ setIsPanelOpen(true);
+ // Scroll to ensure selected item is visible after panel opens
+ requestAnimationFrame(() => {
+ virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
+ });
+ },
+ [setActiveIndex],
+ );
+
+ const handleClose = useCallback(() => {
+ setIsPanelOpen(false);
+ }, []);
+
+ if (isLoading) {
+ return {loadingMessage}
;
+ }
+
+ if (events.length === 0 && !error) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+
(
+
+ {header ??
}
+
+ {error}
+
+ )
+ }
+ render={(event, index) => (
+
+ {renderRow({
+ event,
+ index,
+ isActive: index === activeIndex,
+ onClick: () => handleRowClick(index),
+ })}
+
+ )}
+ />
+
+ )}
+ secondSlot={
+ activeEvent != null && renderDetail && isPanelOpen
+ ? ({ style }) => (
+
+
+
+
+
+ {renderDetail({
+ event: activeEvent,
+ index: activeIndex ?? 0,
+ onClose: handleClose,
+ })}
+
+
+ )
+ : null
+ }
+ />
+
+ );
+}
+
+export type EventDetailAction =
+ | {
+ type?: "button";
+ /** Unique key for React */
+ key: string;
+ /** Button label */
+ label: string;
+ /** Optional icon */
+ icon?: ReactNode;
+ /** Click handler */
+ onClick: () => void;
+ }
+ | {
+ type: "select";
+ /** Unique key for React */
+ key: string;
+ /** Select label */
+ label: string;
+ /** Selected value */
+ value: string;
+ /** Select options */
+ options: SelectProps["options"];
+ /** Change handler */
+ onChange: (value: string) => void;
+ };
+
+interface EventDetailHeaderProps {
+ title: string;
+ prefix?: ReactNode;
+ timestamp?: string;
+ actions?: EventDetailAction[];
+ copyText?: string | (() => Promise);
+ onClose?: () => void;
+}
+
+export function EventDetailHeader({
+ title,
+ prefix,
+ timestamp,
+ actions,
+ copyText,
+ onClose,
+}: EventDetailHeaderProps) {
+ const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), "HH:mm:ss.SSS") : null;
+
+ return (
+
+
+ {prefix}
+ {title}
+
+
+ {actions?.map((action) =>
+ action.type === "select" ? (
+
+
+
+ ) : (
+
+ {action.icon}
+ {action.label}
+
+ ),
+ )}
+ {copyText != null && (
+
+ )}
+ {formattedTime && (
+ {formattedTime}
+ )}
+ {onClose != null && (
+ 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
+ )}
+ >
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/EventViewerRow.tsx b/apps/yaak-client/components/core/EventViewerRow.tsx
new file mode 100644
index 0000000..6247dfc
--- /dev/null
+++ b/apps/yaak-client/components/core/EventViewerRow.tsx
@@ -0,0 +1,38 @@
+import classNames from "classnames";
+import { format } from "date-fns";
+import type { ReactNode } from "react";
+
+interface EventViewerRowProps {
+ isActive: boolean;
+ onClick: () => void;
+ icon: ReactNode;
+ content: ReactNode;
+ timestamp?: string;
+}
+
+export function EventViewerRow({
+ isActive,
+ onClick,
+ icon,
+ content,
+ timestamp,
+}: EventViewerRowProps) {
+ return (
+
+
+ {icon}
+ {content}
+ {timestamp && {format(`${timestamp}Z`, "HH:mm:ss.SSS")}
}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Hotkey.tsx b/apps/yaak-client/components/core/Hotkey.tsx
new file mode 100644
index 0000000..faa86ce
--- /dev/null
+++ b/apps/yaak-client/components/core/Hotkey.tsx
@@ -0,0 +1,45 @@
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useFormattedHotkey } from "../../hooks/useHotKey";
+
+interface Props {
+ action: HotkeyAction | null;
+ className?: string;
+ variant?: "text" | "with-bg";
+}
+
+export function Hotkey({ action, className, variant }: Props) {
+ const labelParts = useFormattedHotkey(action);
+ if (labelParts === null) {
+ return null;
+ }
+
+ return ;
+}
+
+interface HotkeyRawProps {
+ labelParts: string[];
+ className?: string;
+ variant?: "text" | "with-bg";
+}
+
+export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
+ return (
+
+ {labelParts.map((char, index) => (
+ // oxlint-disable-next-line react/no-array-index-key
+
+ {char}
+
+ ))}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/HotkeyLabel.tsx b/apps/yaak-client/components/core/HotkeyLabel.tsx
new file mode 100644
index 0000000..ab2e736
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyLabel.tsx
@@ -0,0 +1,15 @@
+import classNames from "classnames";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useHotkeyLabel } from "../../hooks/useHotKey";
+
+interface Props {
+ action: HotkeyAction;
+ className?: string;
+}
+
+export function HotkeyLabel({ action, className }: Props) {
+ const label = useHotkeyLabel(action);
+ return (
+ {label}
+ );
+}
diff --git a/apps/yaak-client/components/core/HotkeyList.test.tsx b/apps/yaak-client/components/core/HotkeyList.test.tsx
new file mode 100644
index 0000000..47d99f4
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyList.test.tsx
@@ -0,0 +1,25 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, test, vi } from "vite-plus/test";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { HotkeyList } from "./HotkeyList";
+
+vi.mock("./Hotkey", () => ({
+ Hotkey: ({ action }: { action: HotkeyAction }) =>
+ action === "sidebar.selected.move" ? null : {action} ,
+}));
+
+vi.mock("./HotkeyLabel", () => ({
+ HotkeyLabel: ({ action }: { action: HotkeyAction }) => {action} ,
+}));
+
+describe("HotkeyList", () => {
+ test("keeps a grid cell for actions without a shortcut", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain(
+ 'sidebar.selected.move
request.send ',
+ );
+ });
+});
diff --git a/apps/yaak-client/components/core/HotkeyList.tsx b/apps/yaak-client/components/core/HotkeyList.tsx
new file mode 100644
index 0000000..91f9025
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyList.tsx
@@ -0,0 +1,31 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { Fragment } from "react";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { Hotkey } from "./Hotkey";
+import { HotkeyLabel } from "./HotkeyLabel";
+
+interface Props {
+ hotkeys: HotkeyAction[];
+ bottomSlot?: ReactNode;
+ className?: string;
+}
+
+export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
+ return (
+
+
+ {hotkeys.map((hotkey) => (
+
+
+ {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
+
+
+
+
+ ))}
+ {bottomSlot}
+
+
+ );
+};
diff --git a/apps/yaak-client/components/core/HttpMethodTag.tsx b/apps/yaak-client/components/core/HttpMethodTag.tsx
new file mode 100644
index 0000000..0184c2b
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpMethodTag.tsx
@@ -0,0 +1,91 @@
+import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
+import { settingsAtom } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { memo } from "react";
+
+interface Props {
+ request: HttpRequest | GrpcRequest | WebsocketRequest;
+ className?: string;
+ short?: boolean;
+ noAlias?: boolean;
+}
+
+const methodNames: Record = {
+ get: "GET",
+ put: "PUT",
+ post: "POST",
+ patch: "PTCH",
+ delete: "DELE",
+ options: "OPTN",
+ head: "HEAD",
+ query: "QURY",
+ graphql: "GQL",
+ grpc: "GRPC",
+ websocket: "WS",
+};
+
+export const HttpMethodTag = memo(function HttpMethodTag({
+ request,
+ className,
+ short,
+ noAlias,
+}: Props) {
+ const method =
+ request.model === "http_request" && request.bodyType === "graphql" && !noAlias
+ ? "graphql"
+ : request.model === "grpc_request"
+ ? "grpc"
+ : request.model === "websocket_request"
+ ? "websocket"
+ : request.method;
+
+ return ;
+});
+
+export function HttpMethodTagRaw({
+ className,
+ method,
+ short,
+ forceColor,
+}: {
+ method: string;
+ className?: string;
+ short?: boolean;
+ forceColor?: boolean;
+}) {
+ let label = method.toUpperCase();
+ if (short) {
+ label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
+ label = label.padEnd(4, " ");
+ }
+
+ const m = method.toUpperCase();
+
+ const settings = useAtomValue(settingsAtom);
+ const colored = forceColor || settings.coloredMethods;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx
new file mode 100644
index 0000000..93709aa
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx
@@ -0,0 +1,45 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { useEffect, useRef, useState } from "react";
+
+interface Props {
+ response: HttpResponse;
+}
+
+export function HttpResponseDurationTag({ response }: Props) {
+ const [fallbackElapsed, setFallbackElapsed] = useState(0);
+ const timeout = useRef>(undefined);
+
+ // Calculate the duration of the response for use when the response hasn't finished yet
+ useEffect(() => {
+ clearInterval(timeout.current);
+ if (response.state === "closed") return;
+ timeout.current = setInterval(() => {
+ setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime());
+ }, 100);
+ return () => clearInterval(timeout.current);
+ }, [response.createdAt, response.state]);
+
+ const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : "--";
+ const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
+
+ const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed;
+
+ return (
+
+ {formatMillis(elapsed)}
+
+ );
+}
+
+export function formatMillis(ms: number) {
+ if (ms < 1000) {
+ return `${ms} ms`;
+ }
+ if (ms < 60_000) {
+ const seconds = (ms / 1000).toFixed(ms < 10_000 ? 1 : 0);
+ return `${seconds} s`;
+ }
+ const minutes = Math.floor(ms / 60_000);
+ const seconds = Math.round((ms % 60_000) / 1000);
+ return `${minutes}m ${seconds}s`;
+}
diff --git a/apps/yaak-client/components/core/HttpStatusTag.tsx b/apps/yaak-client/components/core/HttpStatusTag.tsx
new file mode 100644
index 0000000..a705f52
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpStatusTag.tsx
@@ -0,0 +1,55 @@
+import type { HttpResponse, HttpResponseState } from "@yaakapp-internal/models";
+import classNames from "classnames";
+
+interface Props {
+ response: HttpResponse;
+ className?: string;
+ showReason?: boolean;
+ short?: boolean;
+}
+
+export function HttpStatusTag({ response, ...props }: Props) {
+ const { status, state, statusReason } = response;
+ return ;
+}
+
+export function HttpStatusTagRaw({
+ status,
+ state,
+ className,
+ showReason,
+ statusReason,
+ short,
+}: Omit & {
+ status: number | string;
+ state?: HttpResponseState;
+ statusReason?: string | null;
+}) {
+ let colorClass: string;
+ let label = `${status}`;
+ const statusN = typeof status === "number" ? status : parseInt(status, 10);
+
+ if (state === "initialized") {
+ label = short ? "CONN" : "CONNECTING";
+ colorClass = "text-text-subtle";
+ } else if (statusN < 100) {
+ label = short ? "ERR" : "ERROR";
+ colorClass = "text-danger";
+ } else if (statusN < 200) {
+ colorClass = "text-info";
+ } else if (statusN < 300) {
+ colorClass = "text-success";
+ } else if (statusN < 400) {
+ colorClass = "text-primary";
+ } else if (statusN < 500) {
+ colorClass = "text-warning";
+ } else {
+ colorClass = "text-danger";
+ }
+
+ return (
+
+ {label} {showReason && statusReason}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/IconButton.tsx b/apps/yaak-client/components/core/IconButton.tsx
new file mode 100644
index 0000000..26b642a
--- /dev/null
+++ b/apps/yaak-client/components/core/IconButton.tsx
@@ -0,0 +1,37 @@
+import {
+ IconButton as BaseIconButton,
+ type IconButtonProps as BaseIconButtonProps,
+} from "@yaakapp-internal/ui";
+import { forwardRef, useImperativeHandle, useRef } from "react";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
+
+export type IconButtonProps = BaseIconButtonProps & {
+ hotkeyAction?: HotkeyAction;
+ hotkeyLabelOnly?: boolean;
+ hotkeyPriority?: number;
+};
+
+export const IconButton = forwardRef(function IconButton(
+ { hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
+ ref,
+) {
+ const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
+ const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
+
+ const buttonRef = useRef(null);
+ useImperativeHandle(
+ ref,
+ () => buttonRef.current,
+ );
+
+ useHotKey(
+ hotkeyAction ?? null,
+ () => {
+ buttonRef.current?.click();
+ },
+ { priority: hotkeyPriority, enable: !hotkeyLabelOnly },
+ );
+
+ return ;
+});
diff --git a/apps/yaak-client/components/core/IconTooltip.tsx b/apps/yaak-client/components/core/IconTooltip.tsx
new file mode 100644
index 0000000..3886048
--- /dev/null
+++ b/apps/yaak-client/components/core/IconTooltip.tsx
@@ -0,0 +1,30 @@
+import { Icon, type IconProps } from "@yaakapp-internal/ui";
+import type { TooltipProps } from "./Tooltip";
+import { Tooltip } from "./Tooltip";
+
+type Props = Omit & {
+ icon?: IconProps["icon"];
+ iconSize?: IconProps["size"];
+ iconColor?: IconProps["color"];
+ className?: string;
+ tabIndex?: number;
+};
+
+export function IconTooltip({
+ content,
+ icon = "info",
+ iconColor,
+ iconSize,
+ ...tooltipProps
+}: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Input.tsx b/apps/yaak-client/components/core/Input.tsx
new file mode 100644
index 0000000..c006327
--- /dev/null
+++ b/apps/yaak-client/components/core/Input.tsx
@@ -0,0 +1,677 @@
+import type { EditorView } from "@codemirror/view";
+import type { Color } from "@yaakapp-internal/plugins";
+import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createFastMutation } from "../../hooks/useFastMutation";
+import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
+import { useStateWithDeps } from "../../hooks/useStateWithDeps";
+import { copyToClipboard } from "../../lib/copy";
+import {
+ analyzeTemplate,
+ convertTemplateToInsecure,
+ convertTemplateToSecure,
+} from "../../lib/encryption";
+import { generateId } from "../../lib/generateId";
+import {
+ setupOrConfigureEncryption,
+ withEncryptionEnabled,
+} from "../../lib/setupOrConfigureEncryption";
+import { Button } from "./Button";
+import type { DropdownItem } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+import type { EditorProps } from "./Editor/Editor";
+import type { GenericCompletion } from "./Editor/genericCompletion";
+import { Editor } from "./Editor/LazyEditor";
+import { IconButton } from "./IconButton";
+import { IconTooltip } from "./IconTooltip";
+import { Label } from "./Label";
+
+export type InputProps = Pick<
+ EditorProps,
+ | "language"
+ | "autocomplete"
+ | "forcedEnvironmentId"
+ | "forceUpdateKey"
+ | "disabled"
+ | "autoFocus"
+ | "autoSelect"
+ | "autocompleteVariables"
+ | "autocompleteFunctions"
+ | "onKeyDown"
+ | "readOnly"
+> & {
+ className?: string;
+ containerClassName?: string;
+ inputWrapperClassName?: string;
+ defaultValue?: string | null;
+ disableObscureToggle?: boolean;
+ fullHeight?: boolean;
+ hideLabel?: boolean;
+ help?: ReactNode;
+ label: ReactNode;
+ labelClassName?: string;
+ labelPosition?: "top" | "left";
+ leftSlot?: ReactNode;
+ multiLine?: boolean;
+ name?: string;
+ onBlur?: () => void;
+ onChange?: (value: string) => void;
+ onFocus?: () => void;
+ onPaste?: (value: string) => void;
+ onPasteOverwrite?: EditorProps["onPasteOverwrite"];
+ placeholder?: string;
+ required?: boolean;
+ rightSlot?: ReactNode;
+ /**
+ * Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
+ * without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
+ * wanting the affordance (the URL bar completes from history, and a chevron there would be
+ * noise). Renders nothing when there are no options to show.
+ */
+ showOptionsPicker?: boolean;
+ size?: "2xs" | "xs" | "sm" | "md" | "auto";
+ stateKey: EditorProps["stateKey"];
+ extraExtensions?: EditorProps["extraExtensions"];
+ tint?: Color;
+ type?: "text" | "password";
+ validate?: boolean | ((v: string) => boolean);
+ wrapLines?: boolean;
+ setRef?: (h: InputHandle | null) => void;
+};
+
+export interface InputHandle {
+ focus: () => void;
+ isFocused: () => boolean;
+ value: () => string;
+ selectAll: () => void;
+ dispatch: EditorView["dispatch"];
+}
+
+export function Input({ type, ...props }: InputProps) {
+ // If it's a password and template functions are supported (ie. secure(...)) then
+ // use the encrypted input component.
+ if (type === "password" && props.autocompleteFunctions) {
+ return ;
+ }
+ return ;
+}
+
+function BaseInput({
+ className,
+ containerClassName,
+ defaultValue,
+ disableObscureToggle,
+ disabled,
+ forceUpdateKey,
+ fullHeight,
+ help,
+ hideLabel,
+ inputWrapperClassName,
+ label,
+ labelClassName,
+ labelPosition = "top",
+ leftSlot,
+ multiLine,
+ onBlur,
+ onChange,
+ onFocus,
+ onPaste,
+ onPasteOverwrite,
+ placeholder,
+ readOnly,
+ required,
+ rightSlot,
+ showOptionsPicker,
+ size = "md",
+ stateKey,
+ tint,
+ type = "text",
+ validate,
+ wrapLines,
+ setRef,
+ ...props
+}: InputProps) {
+ const [focused, setFocused] = useState(false);
+ const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
+ const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]);
+ const editorRef = useRef(null);
+ const skipNextFocus = useRef(false);
+
+ const handle = useMemo(
+ () => ({
+ focus: () => {
+ if (editorRef.current == null) return;
+ const anchor = editorRef.current.state.doc.length;
+ skipNextFocus.current = true;
+ editorRef.current.focus();
+ editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
+ },
+ isFocused: () => editorRef.current?.hasFocus ?? false,
+ value: () => editorRef.current?.state.doc.toString() ?? "",
+ dispatch: (...args) => {
+ // oxlint-disable-next-line no-explicit-any
+ editorRef.current?.dispatch(...(args as any));
+ },
+ selectAll() {
+ if (editorRef.current == null) return;
+ editorRef.current.focus();
+ editorRef.current.dispatch({
+ selection: { anchor: 0, head: editorRef.current.state.doc.length },
+ });
+ },
+ }),
+ [],
+ );
+
+ // Replace the whole value with a picked option. Dispatching runs it through the editor's
+ // onChange, same as typing, so callers see one consistent change path.
+ const insertOption = useCallback((value: string) => {
+ const view = editorRef.current;
+ if (view == null) return;
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
+ view.focus();
+ }, []);
+
+ const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
+
+ const setEditorRef = useCallback(
+ (h: EditorView | null) => {
+ editorRef.current = h;
+ setRef?.(handle);
+ },
+ [handle, setRef],
+ );
+
+ useEffect(() => {
+ const fn = () => {
+ skipNextFocus.current = true;
+ };
+ window.addEventListener("focus", fn);
+ return () => {
+ window.removeEventListener("focus", fn);
+ };
+ }, []);
+
+ const handleFocus = useCallback(() => {
+ if (readOnly) return;
+
+ if (!skipNextFocus.current) {
+ editorRef.current?.dispatch({
+ selection: { anchor: 0, head: editorRef.current.state.doc.length },
+ });
+ }
+
+ setFocused(true);
+ onFocus?.();
+ skipNextFocus.current = false;
+ }, [onFocus, readOnly]);
+
+ const handleBlur = useCallback(async () => {
+ setFocused(false);
+ // Move selection to the end on blur
+ const anchor = editorRef.current?.state.doc.length ?? 0;
+ editorRef.current?.dispatch({
+ selection: { anchor, head: anchor },
+ });
+ onBlur?.();
+ }, [onBlur]);
+
+ const id = useRef(`input-${generateId()}`);
+ const editorClassName = classNames(
+ className,
+ "bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder",
+ );
+
+ const isValid = useMemo(() => {
+ if (required && !validateRequire(defaultValue ?? "")) return false;
+ if (typeof validate === "boolean") return validate;
+ if (typeof validate === "function" && !validate(defaultValue ?? "")) return false;
+ return true;
+ }, [required, defaultValue, validate]);
+
+ const handleChange = useCallback(
+ (value: string) => {
+ onChange?.(value);
+ setHasChanged(true);
+ },
+ [onChange, setHasChanged],
+ );
+
+ const wrapperRef = useRef(null);
+
+ // Submit the nearest form on Enter key press
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key !== "Enter") return;
+
+ const form = wrapperRef.current?.closest("form");
+ if (!isValid || form == null) return;
+
+ form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
+ },
+ [isValid],
+ );
+
+ return (
+
+
+ {label}
+
+
+ {tint != null && (
+
+ )}
+ {leftSlot}
+
+
+
+ {type === "password" && !disableObscureToggle && (
+ setObscured((o) => !o)}
+ />
+ )}
+ {pickerOptions.length > 0 && (
+
+ )}
+ {rightSlot}
+
+
+ );
+}
+
+/**
+ * Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
+ * typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
+ * short name (e.g. a User-Agent).
+ *
+ * Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
+ * options, so this would only add a stop between fields. Hidden when the nearest container is
+ * narrow and the input needs the width more — written as a `max` query so it stays visible for
+ * inputs that have no container ancestor at all.
+ */
+function OptionsPickerDropdown({
+ options,
+ onSelect,
+}: {
+ options: GenericCompletion[];
+ onSelect: (value: string) => void;
+}) {
+ const items = useMemo(
+ () =>
+ options.map((o) => ({
+ label: o.label,
+ onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
+ })),
+ [options, onSelect],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function validateRequire(v: string) {
+ return v.length > 0;
+}
+
+type PasswordFieldType = "text" | "encrypted";
+
+function EncryptionInput({
+ defaultValue,
+ onChange,
+ autocompleteFunctions,
+ autocompleteVariables,
+ forceUpdateKey: ogForceUpdateKey,
+ setRef,
+ ...props
+}: InputProps) {
+ const isEncryptionEnabled = useIsEncryptionEnabled();
+ const [state, setState] = useStateWithDeps<{
+ fieldType: PasswordFieldType;
+ value: string | null;
+ security: ReturnType | null;
+ obscured: boolean;
+ error: string | null;
+ }>(
+ {
+ fieldType: isEncryptionEnabled ? "encrypted" : "text",
+ value: null,
+ security: null,
+ obscured: true,
+ error: null,
+ },
+ [ogForceUpdateKey],
+ );
+
+ const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (state.value != null) {
+ // We already configured it
+ return;
+ }
+
+ const security = analyzeTemplate(defaultValue ?? "");
+ if (analyzeTemplate(defaultValue ?? "") === "global_secured") {
+ // Lazily update value to decrypted representation
+ templateToInsecure.mutate(defaultValue ?? "", {
+ onSuccess: (value) => {
+ setState({ fieldType: "encrypted", security, value, obscured: true, error: null });
+ // We're calling this here because we want the input to be fully initialized so the caller
+ // can do stuff like change the selection.
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ },
+ onError: (value) => {
+ setState({
+ fieldType: "encrypted",
+ security,
+ value: null,
+ error: String(value),
+ obscured: true,
+ });
+ },
+ });
+ } else if (isEncryptionEnabled && !defaultValue) {
+ // Default to encrypted field for new encrypted inputs
+ setState({ fieldType: "encrypted", security, value: "", obscured: true, error: null });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ } else if (isEncryptionEnabled) {
+ // Don't obscure plain text when encryption is enabled
+ setState({
+ fieldType: "text",
+ security,
+ value: defaultValue ?? "",
+ obscured: false,
+ error: null,
+ });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ } else {
+ // Don't obscure plain text when encryption is disabled
+ setState({
+ fieldType: "text",
+ security,
+ value: defaultValue ?? "",
+ obscured: true,
+ error: null,
+ });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ }
+ }, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
+
+ const handleChange = useCallback(
+ (value: string, fieldType: PasswordFieldType) => {
+ if (fieldType === "encrypted") {
+ templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
+ } else {
+ onChange?.(value);
+ }
+ setState((s) => {
+ // We can't analyze when encrypted because we don't have the raw value, so assume it's secured
+ const security = fieldType === "encrypted" ? "global_secured" : analyzeTemplate(value);
+ // Reset obscured value when the field type is being changed
+ const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== "text";
+ return { fieldType, value, security, obscured, error: s.error };
+ });
+ },
+ [onChange, setState],
+ );
+
+ const handleInputChange = useCallback(
+ (value: string) => {
+ if (state.fieldType != null) {
+ handleChange(value, state.fieldType);
+ }
+ },
+ [handleChange, state],
+ );
+
+ const setInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
+ const handleFieldTypeChange = useCallback(
+ (newFieldType: PasswordFieldType) => {
+ const { value, fieldType } = state;
+ if (value == null || fieldType === newFieldType) {
+ return;
+ }
+
+ withEncryptionEnabled(async () => {
+ const newValue = await convertTemplateToInsecure(value);
+ handleChange(newValue, newFieldType);
+ });
+ },
+ [handleChange, state],
+ );
+
+ const dropdownItems = useMemo(
+ () => [
+ {
+ label: state.obscured ? "Show" : "Hide",
+ disabled: isEncryptionEnabled && state.fieldType === "text",
+ leftSlot: ,
+ onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
+ },
+ {
+ label: "Copy",
+ leftSlot: ,
+ hidden: !state.value,
+ onSelect: () => copyToClipboard(state.value ?? ""),
+ },
+ { type: "separator" },
+ {
+ label: state.fieldType === "text" ? "Encrypt Field" : "Decrypt Field",
+ leftSlot: ,
+ onSelect: () => handleFieldTypeChange(state.fieldType === "text" ? "encrypted" : "text"),
+ },
+ ],
+ [
+ handleFieldTypeChange,
+ isEncryptionEnabled,
+ setState,
+ state.fieldType,
+ state.obscured,
+ state.value,
+ ],
+ );
+
+ let tint: InputProps["tint"];
+ if (!isEncryptionEnabled) {
+ tint = undefined;
+ } else if (state.fieldType === "encrypted") {
+ tint = "info";
+ } else if (state.security === "local_secured") {
+ tint = "secondary";
+ } else if (state.security === "insecure") {
+ tint = "notice";
+ }
+
+ const rightSlot = useMemo(() => {
+ let icon: IconProps["icon"];
+ if (isEncryptionEnabled) {
+ icon = state.security === "insecure" ? "shield_off" : "shield_check";
+ } else {
+ icon = state.obscured ? "eye_closed" : "eye";
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
+
+ const type = state.obscured ? "password" : "text";
+
+ if (state.error) {
+ return (
+ }
+ onClick={() => {
+ setupOrConfigureEncryption();
+ }}
+ >
+ {state.error.replace(/^Render Error: /i, "")}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const templateToSecure = createFastMutation({
+ mutationKey: ["template-to-secure"],
+ mutationFn: convertTemplateToSecure,
+});
+
+const templateToInsecure = createFastMutation({
+ mutationKey: ["template-to-insecure"],
+ mutationFn: convertTemplateToInsecure,
+ disableToastError: true,
+});
diff --git a/apps/yaak-client/components/core/JsonAttributeTree.tsx b/apps/yaak-client/components/core/JsonAttributeTree.tsx
new file mode 100644
index 0000000..58e6a4d
--- /dev/null
+++ b/apps/yaak-client/components/core/JsonAttributeTree.tsx
@@ -0,0 +1,146 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useMemo, useState } from "react";
+import { Icon } from "@yaakapp-internal/ui";
+
+interface Props {
+ depth?: number;
+ // oxlint-disable-next-line no-explicit-any -- none
+ attrValue: any;
+ attrKey?: string | number;
+ attrKeyJsonPath?: string;
+ className?: string;
+}
+
+export const JsonAttributeTree = ({
+ depth = 0,
+ attrKey,
+ attrValue,
+ attrKeyJsonPath,
+ className,
+}: Props) => {
+ attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
+
+ const [isExpanded, setIsExpanded] = useState(true);
+ const toggleExpanded = () => setIsExpanded((v) => !v);
+
+ const { isExpandable, children, label, labelClassName } = useMemo<{
+ isExpandable: boolean;
+ children: ReactNode;
+ label?: string;
+ labelClassName?: string;
+ }>(() => {
+ const jsonType = Object.prototype.toString.call(attrValue);
+ if (jsonType === "[object Object]") {
+ return {
+ children: isExpanded
+ ? Object.keys(attrValue)
+ .sort((a, b) => a.localeCompare(b))
+ .flatMap((k) => (
+
+ ))
+ : null,
+ isExpandable: Object.keys(attrValue).length > 0,
+ label: isExpanded ? `{${Object.keys(attrValue).length || " "}}` : "{⋯}",
+ labelClassName: "text-text-subtlest",
+ };
+ }
+ if (jsonType === "[object Array]") {
+ return {
+ children: isExpanded
+ ? // oxlint-disable-next-line no-explicit-any -- none
+ attrValue.flatMap((v: any, i: number) => (
+
+ ))
+ : null,
+ isExpandable: attrValue.length > 0,
+ label: isExpanded ? `[${attrValue.length || " "}]` : "[⋯]",
+ labelClassName: "text-text-subtlest",
+ };
+ }
+ return {
+ children: null,
+ isExpandable: false,
+ label: jsonType === "[object String]" ? `"${attrValue}"` : `${attrValue}`,
+ labelClassName: classNames(
+ jsonType === "[object Boolean]" && "text-primary",
+ jsonType === "[object Number]" && "text-info",
+ jsonType === "[object String]" && "text-notice",
+ jsonType === "[object Null]" && "text-danger",
+ ),
+ };
+ }, [attrValue, attrKeyJsonPath, isExpanded, depth]);
+
+ const labelEl = (
+
+ {label}
+
+ );
+ return (
+
+
+ {isExpandable ? (
+
+
+
+ {attrKey === undefined ? "$" : attrKey}:
+
+ {labelEl}
+
+ ) : (
+ <>
+
+ {attrKey}:
+
+ {labelEl}
+ >
+ )}
+
+ {children &&
{children}
}
+
+ );
+};
+
+function joinObjectKey(baseKey: string | undefined, key: string): string {
+ const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
+
+ if (baseKey == null) return quotedKey;
+ return `${baseKey}.${quotedKey}`;
+}
+
+function joinArrayKey(baseKey: string | undefined, index: number): string {
+ return `${baseKey ?? ""}[${index}]`;
+}
diff --git a/apps/yaak-client/components/core/KeyValueRow.tsx b/apps/yaak-client/components/core/KeyValueRow.tsx
new file mode 100644
index 0000000..75847fc
--- /dev/null
+++ b/apps/yaak-client/components/core/KeyValueRow.tsx
@@ -0,0 +1,106 @@
+import classNames from "classnames";
+import type { HTMLAttributes, ReactElement, ReactNode } from "react";
+import { CopyIconButton } from "../CopyIconButton";
+
+interface Props {
+ children:
+ | ReactElement>
+ | (ReactElement> | null)[];
+ selectable?: boolean;
+}
+
+export function KeyValueRows({ children, selectable }: Props) {
+ const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
+ return (
+
+
+ {childArray.map((child, i) => (
+ // oxlint-disable-next-line react/no-array-index-key
+ {child}
+ ))}
+
+
+ );
+}
+
+interface KeyValueRowProps {
+ label: ReactNode;
+ children: ReactNode;
+ rightSlot?: ReactNode;
+ leftSlot?: ReactNode;
+ align?: "top" | "middle";
+ labelClassName?: string;
+ labelColor?: "secondary" | "primary" | "info";
+ enableCopy?: boolean;
+ copyText?: string;
+}
+
+export function KeyValueRow({
+ label,
+ children,
+ rightSlot,
+ leftSlot,
+ align = "top",
+ labelColor = "secondary",
+ labelClassName,
+ enableCopy,
+ copyText,
+}: KeyValueRowProps) {
+ const textToCopy =
+ copyText ??
+ (typeof children === "string" || typeof children === "number" ? `${children}` : null);
+ const copyTitle =
+ typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
+ const resolvedRightSlot =
+ rightSlot ??
+ (enableCopy && textToCopy != null ? (
+
+ ) : null);
+
+ return (
+ <>
+
+ {label}
+
+
+
+ {leftSlot ??
}
+ {children}
+ {resolvedRightSlot ? (
+
{resolvedRightSlot}
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/yaak-client/components/core/Label.tsx b/apps/yaak-client/components/core/Label.tsx
new file mode 100644
index 0000000..a1f2cbb
--- /dev/null
+++ b/apps/yaak-client/components/core/Label.tsx
@@ -0,0 +1,49 @@
+import classNames from "classnames";
+import type { HTMLAttributes, ReactNode } from "react";
+import { IconTooltip } from "./IconTooltip";
+
+export function Label({
+ htmlFor,
+ className,
+ children,
+ visuallyHidden,
+ tags = [],
+ required,
+ rightSlot,
+ help,
+ ...props
+}: HTMLAttributes & {
+ htmlFor: string | null;
+ required?: boolean;
+ tags?: string[];
+ visuallyHidden?: boolean;
+ rightSlot?: ReactNode;
+ children: ReactNode;
+ help?: ReactNode;
+}) {
+ return (
+
+
+ {children}
+ {required === true && * }
+
+ {tags.map((tag, i) => (
+ // oxlint-disable-next-line react/no-array-index-key
+
+ ({tag})
+
+ ))}
+ {help && }
+ {rightSlot && {rightSlot}
}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Link.tsx b/apps/yaak-client/components/core/Link.tsx
new file mode 100644
index 0000000..297e205
--- /dev/null
+++ b/apps/yaak-client/components/core/Link.tsx
@@ -0,0 +1,63 @@
+import { Link as RouterLink } from "@tanstack/react-router";
+import { platform } from "@yaakapp-internal/platform";
+import classNames from "classnames";
+import type { HTMLAttributes } from "react";
+import { appInfo } from "../../lib/appInfo";
+import { Icon } from "@yaakapp-internal/ui";
+
+interface Props extends HTMLAttributes {
+ href: string;
+ noUnderline?: boolean;
+}
+
+export function Link({ href, children, noUnderline, className, ...other }: Props) {
+ const isExternal = href.match(/^https?:\/\//);
+
+ className = classNames(
+ className,
+ "relative",
+ "inline-flex items-center hover:underline group",
+ !noUnderline && "underline",
+ );
+
+ if (isExternal) {
+ const isYaakLink = href.startsWith("https://yaak.app");
+ let finalHref = href;
+ if (isYaakLink) {
+ const url = new URL(href);
+ url.searchParams.set("ref", appInfo.identifier);
+ finalHref = url.toString();
+ }
+ return (
+ // eslint-disable-next-line react/jsx-no-target-blank
+ {
+ e.preventDefault();
+ void platform.openUrl(finalHref);
+ }}
+ className={className}
+ {...other}
+ >
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function FeedbackLink() {
+ return Feedback;
+}
diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx
new file mode 100644
index 0000000..58a6e74
--- /dev/null
+++ b/apps/yaak-client/components/core/PairEditor.tsx
@@ -0,0 +1,971 @@
+import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ pointerWithin,
+ useDraggable,
+ useDroppable,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import classNames from "classnames";
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
+import { useRandomKey } from "../../hooks/useRandomKey";
+import { useToggle } from "../../hooks/useToggle";
+import { languageFromContentType } from "../../lib/contentType";
+import { showDialog } from "../../lib/dialog";
+import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui";
+import { showPrompt } from "../../lib/prompt";
+import { SelectFile } from "../SelectFile";
+import { Button } from "./Button";
+import { Checkbox } from "./Checkbox";
+import type { DropdownItem } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+import type { EditorProps } from "./Editor/Editor";
+import type { GenericCompletionConfig } from "./Editor/genericCompletion";
+import { Editor } from "./Editor/LazyEditor";
+import { Icon } from "@yaakapp-internal/ui";
+import { IconButton } from "./IconButton";
+import type { InputHandle, InputProps } from "./Input";
+import { Input } from "./Input";
+import { ensurePairId } from "./PairEditor.util";
+import type { RadioDropdownItem } from "./RadioDropdown";
+import { RadioDropdown } from "./RadioDropdown";
+import { platform } from "@yaakapp-internal/platform";
+
+export interface PairEditorHandle {
+ /**
+ * Focus a row's name field once it's able to take focus. Focus can't land immediately when the
+ * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
+ * active — so this retries for up to ~1s. A newer focus request cancels a pending one.
+ */
+ focusName(id: string): void;
+ /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
+ focusValue(id: string): void;
+}
+
+/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
+const MAX_FOCUS_ATTEMPTS = 60;
+
+export type PairEditorProps = {
+ allowFileValues?: boolean;
+ allowMultilineValues?: boolean;
+ className?: string;
+ forcedEnvironmentId?: string;
+ forceUpdateKey?: string;
+ nameAutocomplete?: GenericCompletionConfig;
+ nameAutocompleteFunctions?: boolean;
+ nameAutocompleteVariables?: boolean;
+ namePlaceholder?: string;
+ nameValidate?: InputProps["validate"];
+ noScroll?: boolean;
+ onChange: (pairs: PairWithId[]) => void;
+ pairs: EditablePair[];
+ stateKey: InputProps["stateKey"];
+ setRef?: (n: PairEditorHandle) => void;
+ valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
+ valueAutocompleteFunctions?: boolean;
+ valueAutocompleteVariables?: boolean | "environment";
+ valuePlaceholder?: string;
+ valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]);
+ valueValidate?: InputProps["validate"];
+};
+
+export type Pair = {
+ id?: string;
+ enabled?: boolean;
+ name: string;
+ value: string;
+ contentType?: string;
+ filename?: string;
+ isFile?: boolean;
+};
+
+export type PairWithId = Pair & {
+ id: string;
+};
+
+/**
+ * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
+ * `Pair` stays the shape that gets written to models.
+ */
+export type EditablePair = Pair & {
+ /**
+ * When set, name edits are held until the field blurs and then committed through this, instead
+ * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
+ * the field. For names that can't be written directly, like a URL path placeholder that lives
+ * in the URL itself.
+ */
+ commitName?: (name: string) => boolean;
+};
+
+type EditablePairWithId = EditablePair & {
+ id: string;
+};
+
+/** Strip the editor-only fields, so they can never reach a model write */
+function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
+ return pair;
+}
+
+/** Max number of pairs to show before prompting the user to reveal the rest */
+const MAX_INITIAL_PAIRS = 30;
+
+// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
+// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
+// fast path below would swap in the new data without rebuilding the row editors, leaving any
+// still-focused input showing the old owner's text.
+export function PairEditor(props: PairEditorProps) {
+ return ;
+}
+
+function PairEditorInner({
+ allowFileValues,
+ allowMultilineValues,
+ className,
+ forcedEnvironmentId,
+ forceUpdateKey,
+ nameAutocomplete,
+ nameAutocompleteFunctions,
+ nameAutocompleteVariables,
+ namePlaceholder,
+ nameValidate,
+ noScroll,
+ onChange,
+ pairs: originalPairs,
+ stateKey,
+ valueAutocomplete,
+ valueAutocompleteFunctions,
+ valueAutocompleteVariables,
+ valuePlaceholder,
+ valueType,
+ valueValidate,
+ setRef,
+}: PairEditorProps) {
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const [isDragging, setIsDragging] = useState(null);
+ const [pairs, setPairs] = useState([]);
+ const [showAll, toggleShowAll] = useToggle(false);
+ // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
+ // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
+ const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
+
+ const rowsRef = useRef>({});
+
+ const pendingFocusFrame = useRef(null);
+ useEffect(
+ () => () => {
+ if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
+ },
+ [],
+ );
+
+ const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
+ if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
+
+ let attemptsLeft = MAX_FOCUS_ATTEMPTS;
+ const attempt = () => {
+ pendingFocusFrame.current = null;
+ const row = rowsRef.current[id];
+ const landed = field === "name" ? row?.focusName() : row?.focusValue();
+ if (landed || --attemptsLeft <= 0) return;
+ pendingFocusFrame.current = requestAnimationFrame(attempt);
+ };
+ attempt();
+ }, []);
+
+ const handle = useMemo(
+ () => ({
+ focusName(id: string) {
+ focusWhenReady(id, "name");
+ },
+ focusValue(id: string) {
+ focusWhenReady(id, "value");
+ },
+ }),
+ [focusWhenReady],
+ );
+
+ const initPairEditorRow = useCallback(
+ (id: string, n: RowHandle | null) => {
+ const isLast = id === pairs[pairs.length - 1]?.id;
+ if (isLast) return; // Never add the last pair
+
+ rowsRef.current[id] = n;
+ const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
+
+ // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
+ const ready = validHandles.length >= pairs.length - 1;
+ if (ready) {
+ setRef?.(handle);
+ }
+ },
+ [handle, pairs, setRef],
+ );
+
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
+ useEffect(() => {
+ // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
+ const newPairs: EditablePairWithId[] = [];
+ for (let i = 0; i < originalPairs.length; i++) {
+ const p = originalPairs[i];
+ if (!p) continue; // Make TS happy
+ if (isPairEmpty(p)) continue;
+ newPairs.push(ensurePairId(p));
+ }
+
+ // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
+ // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
+ // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
+ // selection — like tabbing from a placeholder's name into its value.
+ const trailingPair = pairs[pairs.length - 1];
+ const sameRows =
+ trailingPair != null &&
+ isPairEmpty(trailingPair) &&
+ pairs.length === newPairs.length + 1 &&
+ newPairs.every((p, i) => p.id === pairs[i]?.id);
+ if (sameRows) {
+ setPairs([...newPairs, trailingPair]);
+ return;
+ }
+
+ // Add empty last pair if there is none
+ const lastPair = newPairs[newPairs.length - 1];
+ if (lastPair == null || !isPairEmpty(lastPair)) {
+ newPairs.push(emptyPair());
+ }
+
+ setPairs(newPairs);
+ regenerateLocalForceUpdateKey();
+ }, [forceUpdateKey]);
+
+ const setPairsAndSave = useCallback(
+ (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
+ setPairs((oldPairs) => {
+ const pairs = fn(oldPairs);
+ onChange(pairs.map(toPairData));
+ return pairs;
+ });
+ },
+ [onChange],
+ );
+
+ const handleChange = useCallback(
+ (pair: EditablePairWithId) =>
+ setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
+ [setPairsAndSave],
+ );
+
+ const handleDelete = useCallback(
+ (pair: Pair, focusPrevious: boolean) => {
+ if (focusPrevious) {
+ const index = pairs.findIndex((p) => p.id === pair.id);
+ const id = pairs[index - 1]?.id ?? null;
+ rowsRef.current[id ?? "n/a"]?.focusName();
+ }
+ return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
+ },
+ [setPairsAndSave, pairs],
+ );
+
+ const handleFocusName = useCallback(
+ (pair: Pair) => {
+ const isLast = pair.id === pairs[pairs.length - 1]?.id;
+ if (isLast) setPairs([...pairs, emptyPair()]);
+ },
+ [pairs],
+ );
+
+ const handleFocusValue = useCallback(
+ (pair: Pair) => {
+ const isLast = pair.id === pairs[pairs.length - 1]?.id;
+ if (isLast) setPairs([...pairs, emptyPair()]);
+ },
+ [pairs],
+ );
+
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+
+ // dnd-kit: show the “between rows” marker while hovering
+ const onDragMove = useCallback(
+ (e: DragMoveEvent) => {
+ const overId = e.over?.id as string | undefined;
+ if (!overId) return setHoveredIndex(null);
+
+ const overPair = pairs.find((p) => p.id === overId);
+ if (overPair == null) return setHoveredIndex(null);
+
+ const side = computeSideForDragMove(overPair.id, e);
+ const overIndex = pairs.findIndex((p) => p.id === overId);
+ const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
+
+ setHoveredIndex(hoveredIndex);
+ },
+ [pairs],
+ );
+
+ const onDragStart = useCallback(
+ (e: DragStartEvent) => {
+ const pair = pairs.find((p) => p.id === e.active.id);
+ setIsDragging(pair ?? null);
+ },
+ [pairs],
+ );
+
+ const onDragCancel = useCallback(() => setIsDragging(null), []);
+
+ const onDragEnd = useCallback(
+ (e: DragEndEvent) => {
+ setIsDragging(null);
+ setHoveredIndex(null);
+ const activeId = e.active.id as string | undefined;
+ const overId = e.over?.id as string | undefined;
+ if (!activeId || !overId) return;
+
+ const from = pairs.findIndex((p) => p.id === activeId);
+ const baseTo = pairs.findIndex((p) => p.id === overId);
+ const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
+
+ if (from !== -1 && to !== -1 && from !== to) {
+ setPairsAndSave((ps) => {
+ const next = [...ps];
+ const [moved] = next.splice(from, 1);
+ if (moved === undefined) return ps; // Make TS happy
+ next.splice(to > from ? to - 1 : to, 0, moved);
+ return next;
+ });
+ }
+ },
+ [pairs, hoveredIndex, setPairsAndSave],
+ );
+
+ return (
+
+
+
+ {pairs.map((p, i) => {
+ if (!showAll && i > MAX_INITIAL_PAIRS) return null;
+
+ const isLast = i === pairs.length - 1;
+ return (
+
+ {hoveredIndex === i && }
+
+
+ );
+ })}
+ {!showAll && pairs.length > MAX_INITIAL_PAIRS && (
+
+ Show {pairs.length - MAX_INITIAL_PAIRS} More
+
+ )}
+
+ {isDragging && (
+
+ )}
+
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+ );
+}
+
+type PairEditorRowProps = {
+ className?: string;
+ pair: EditablePairWithId;
+ forceFocusNamePairId?: string | null;
+ forceFocusValuePairId?: string | null;
+ onChange?: (pair: EditablePairWithId) => void;
+ onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
+ onFocusName?: (pair: EditablePairWithId) => void;
+ onFocusValue?: (pair: EditablePairWithId) => void;
+ onSubmit?: (pair: EditablePairWithId) => void;
+ isLast?: boolean;
+ disabled?: boolean;
+ disableDrag?: boolean;
+ index: number;
+ isDraggingGlobal?: boolean;
+ setRef?: (id: string, n: RowHandle | null) => void;
+} & Pick<
+ PairEditorProps,
+ | "allowFileValues"
+ | "allowMultilineValues"
+ | "forcedEnvironmentId"
+ | "forceUpdateKey"
+ | "nameAutocomplete"
+ | "nameAutocompleteVariables"
+ | "namePlaceholder"
+ | "nameValidate"
+ | "nameAutocompleteFunctions"
+ | "stateKey"
+ | "valueAutocomplete"
+ | "valueAutocompleteFunctions"
+ | "valueAutocompleteVariables"
+ | "valuePlaceholder"
+ | "valueType"
+ | "valueValidate"
+>;
+
+interface RowHandle {
+ focusName(): boolean;
+ focusValue(): boolean;
+}
+
+export function PairEditorRow({
+ allowFileValues,
+ allowMultilineValues,
+ className,
+ disableDrag,
+ disabled,
+ forceUpdateKey,
+ forcedEnvironmentId,
+ index,
+ isLast,
+ nameAutocomplete,
+ nameAutocompleteFunctions,
+ nameAutocompleteVariables,
+ namePlaceholder,
+ nameValidate,
+ isDraggingGlobal,
+ onChange,
+ onDelete,
+ onFocusName,
+ onFocusValue,
+ pair,
+ stateKey,
+ valueAutocomplete,
+ valueAutocompleteFunctions,
+ valueAutocompleteVariables,
+ valuePlaceholder,
+ valueType,
+ valueValidate,
+ setRef,
+}: PairEditorRowProps) {
+ const nameInputRef = useRef
(null);
+ const valueInputRef = useRef(null);
+ const handle = useRef({
+ focusName() {
+ nameInputRef.current?.focus();
+ return nameInputRef.current?.isFocused() ?? false;
+ },
+ focusValue() {
+ valueInputRef.current?.focus();
+ return valueInputRef.current?.isFocused() ?? false;
+ },
+ });
+
+ const initNameInputRef = useCallback(
+ (n: InputHandle | null) => {
+ nameInputRef.current = n;
+ if (nameInputRef.current && valueInputRef.current) {
+ setRef?.(pair.id, handle.current);
+ }
+ },
+ [pair.id, setRef],
+ );
+
+ const initValueInputRef = useCallback(
+ (n: InputHandle | null) => {
+ valueInputRef.current = n;
+ if (nameInputRef.current && valueInputRef.current) {
+ setRef?.(pair.id, handle.current);
+ }
+ },
+ [pair.id, setRef],
+ );
+
+ const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
+ const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
+ const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
+
+ const handleChangeEnabled = useMemo(
+ () => (enabled: boolean) => onChange?.({ ...pair, enabled }),
+ [onChange, pair],
+ );
+
+ // The name being typed into a deferred-commit field, before it's committed or reverted
+ const pendingName = useRef(null);
+
+ const handleChangeName = useMemo(
+ () => (name: string) => {
+ // Keep the edit local until commit. Writing on every keystroke would reset the editor from
+ // beneath the cursor, since the pairs are derived from the name being edited.
+ if (pair.commitName != null) pendingName.current = name;
+ else onChange?.({ ...pair, name });
+ },
+ [onChange, pair],
+ );
+
+ const revertName = useCallback(() => {
+ const nameInput = nameInputRef.current;
+ if (nameInput != null) {
+ const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
+ nameInput.dispatch({ changes });
+ }
+ pendingName.current = null;
+ }, [pair.name]);
+
+ const handleBlurName = useCallback(() => {
+ if (pair.commitName == null) return;
+
+ const name = pendingName.current;
+ pendingName.current = null;
+ if (name == null || name === pair.name) return;
+ if (!pair.commitName(name)) revertName();
+ }, [pair, revertName]);
+
+ const handleChangeValueText = useMemo(
+ () => (value: string) => onChange?.({ ...pair, value, isFile: false }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueFile = useMemo(
+ () =>
+ ({ filePath }: { filePath: string | null }) =>
+ onChange?.({ ...pair, value: filePath ?? "", isFile: true }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueContentType = useMemo(
+ () => (contentType: string) => onChange?.({ ...pair, contentType }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueFilename = useMemo(
+ () => (filename: string) => onChange?.({ ...pair, filename }),
+ [onChange, pair],
+ );
+
+ const handleEditMultiLineValue = useCallback(
+ () =>
+ showDialog({
+ id: "pair-edit-multiline",
+ size: "dynamic",
+ title: <>Edit {pair.name}>,
+ render: ({ hide }) => (
+
+ ),
+ }),
+ [handleChangeValueText, pair.contentType, pair.name, pair.value],
+ );
+
+ const defaultItems = useMemo(
+ (): DropdownItem[] => [
+ {
+ label: "Edit Multi-line",
+ onSelect: handleEditMultiLineValue,
+ hidden: !allowMultilineValues,
+ },
+ {
+ label: "Delete",
+ onSelect: handleDelete,
+ color: "danger",
+ },
+ ],
+ [allowMultilineValues, handleDelete, handleEditMultiLineValue],
+ );
+
+ const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
+ const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
+
+ // Filter out the current pair name
+ const valueAutocompleteVariablesFiltered = useMemo(() => {
+ if (valueAutocompleteVariables === "environment") {
+ return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
+ }
+ return valueAutocompleteVariables;
+ }, [pair.name, valueAutocompleteVariables]);
+
+ const handleSetRef = useCallback(
+ (n: HTMLDivElement | null) => {
+ setDraggableRef(n);
+ setDroppableRef(n);
+ },
+ [setDraggableRef, setDroppableRef],
+ );
+
+ // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
+ // imply it's already a real row. `Input` handles the rest, including having no options to show.
+ const showOptionsPicker = !isLast;
+
+ return (
+
+
+ {!isLast && !disableDrag ? (
+
+
+
+ ) : (
+
+ )}
+
+ {allowFileValues ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+const fileItems: RadioDropdownItem[] = [
+ { label: "Text", value: "text" },
+ { label: "File", value: "file" },
+];
+
+function FileActionsDropdown({
+ pair,
+ onChangeFile,
+ onChangeText,
+ onChangeContentType,
+ onChangeFilename,
+ onDelete,
+ editMultiLine,
+}: {
+ pair: Pair;
+ onChangeFile: ({ filePath }: { filePath: string | null }) => void;
+ onChangeText: (text: string) => void;
+ onChangeContentType: (contentType: string) => void;
+ onChangeFilename: (filename: string) => void;
+ onDelete: () => void;
+ editMultiLine: () => void;
+}) {
+ const onChange = useCallback(
+ (v: string) => {
+ if (v === "file") onChangeFile({ filePath: "" });
+ else onChangeText("");
+ },
+ [onChangeFile, onChangeText],
+ );
+
+ const itemsAfter = useMemo(
+ () => [
+ {
+ label: "Edit Multi-Line",
+ leftSlot: ,
+ hidden: pair.isFile,
+ onSelect: editMultiLine,
+ },
+ {
+ label: "Set Content-Type",
+ leftSlot: ,
+ onSelect: async () => {
+ const contentType = await showPrompt({
+ id: "content-type",
+ title: "Override Content-Type",
+ label: "Content-Type",
+ required: false,
+ placeholder: "text/plain",
+ defaultValue: pair.contentType ?? "",
+ confirmText: "Set",
+ description: "Leave blank to auto-detect",
+ });
+ if (contentType == null) return;
+ onChangeContentType(contentType);
+ },
+ },
+ {
+ label: "Set File Name",
+ leftSlot: ,
+ onSelect: async () => {
+ console.log("PAIR", pair);
+ const defaultFilename = await platform.files.basename(pair.value ?? "");
+ const filename = await showPrompt({
+ id: "filename",
+ title: "Override Filename",
+ label: "Filename",
+ required: false,
+ placeholder: defaultFilename ?? "myfile.png",
+ defaultValue: pair.filename,
+ confirmText: "Set",
+ description: "Leave blank to use the name of the selected file",
+ });
+ if (filename == null) return;
+ onChangeFilename(filename);
+ },
+ },
+ {
+ label: "Unset File",
+ leftSlot: ,
+ hidden: pair.isFile,
+ onSelect: async () => {
+ onChangeFile({ filePath: null });
+ },
+ },
+ {
+ label: "Delete",
+ onSelect: onDelete,
+ variant: "danger",
+ leftSlot: ,
+ color: "danger",
+ },
+ ],
+ [
+ editMultiLine,
+ onChangeContentType,
+ onChangeFile,
+ onDelete,
+ pair.contentType,
+ pair.isFile,
+ onChangeFilename,
+ pair.filename,
+ pair,
+ ],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function emptyPair(): EditablePairWithId {
+ return ensurePairId({ enabled: true, name: "", value: "" });
+}
+
+function isPairEmpty(pair: Pair): boolean {
+ return !pair.name && !pair.value;
+}
+
+function MultilineEditDialog({
+ defaultValue,
+ contentType,
+ onChange,
+ hide,
+}: {
+ defaultValue: string;
+ contentType: string | null;
+ onChange: (value: string) => void;
+ hide: () => void;
+}) {
+ const [value, setValue] = useState(defaultValue);
+ const language = languageFromContentType(contentType, value);
+ return (
+
+
+
+ {
+ onChange(value);
+ hide();
+ }}
+ >
+ Done
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx
new file mode 100644
index 0000000..27feb01
--- /dev/null
+++ b/apps/yaak-client/components/core/PairEditor.util.tsx
@@ -0,0 +1,10 @@
+import { generateId } from "../../lib/generateId";
+import type { Pair, PairWithId } from "./PairEditor";
+
+// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
+export function ensurePairId(p: T): T & PairWithId {
+ if (typeof p.id === "string") {
+ return p as T & PairWithId;
+ }
+ return { ...p, id: p.id ?? generateId() };
+}
diff --git a/apps/yaak-client/components/core/PairOrBulkEditor.tsx b/apps/yaak-client/components/core/PairOrBulkEditor.tsx
new file mode 100644
index 0000000..977c7da
--- /dev/null
+++ b/apps/yaak-client/components/core/PairOrBulkEditor.tsx
@@ -0,0 +1,38 @@
+import classNames from "classnames";
+import { useKeyValue } from "../../hooks/useKeyValue";
+import { BulkPairEditor } from "./BulkPairEditor";
+import { IconButton } from "./IconButton";
+import type { PairEditorProps } from "./PairEditor";
+import { PairEditor } from "./PairEditor";
+
+interface Props extends PairEditorProps {
+ preferenceName: string;
+ forcedEnvironmentId?: string;
+}
+
+export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
+ const { value: useBulk, set: setUseBulk } = useKeyValue({
+ namespace: "global",
+ key: ["bulk_edit", preferenceName],
+ fallback: false,
+ });
+
+ return (
+
+ {useBulk ?
:
}
+
+ setUseBulk((b) => !b)}
+ icon={useBulk ? "table" : "file_code"}
+ />
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PillButton.tsx b/apps/yaak-client/components/core/PillButton.tsx
new file mode 100644
index 0000000..ba2b642
--- /dev/null
+++ b/apps/yaak-client/components/core/PillButton.tsx
@@ -0,0 +1,14 @@
+import classNames from "classnames";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+
+export function PillButton({ className, ...props }: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PlainInput.tsx b/apps/yaak-client/components/core/PlainInput.tsx
new file mode 100644
index 0000000..c38672e
--- /dev/null
+++ b/apps/yaak-client/components/core/PlainInput.tsx
@@ -0,0 +1,244 @@
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
+import {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { useRandomKey } from "../../hooks/useRandomKey";
+import { useStateWithDeps } from "../../hooks/useStateWithDeps";
+import { generateId } from "../../lib/generateId";
+import { IconButton } from "./IconButton";
+import type { InputProps } from "./Input";
+import { Label } from "./Label";
+
+export type PlainInputProps = Omit<
+ InputProps,
+ | "wrapLines"
+ | "onKeyDown"
+ | "type"
+ | "stateKey"
+ | "autocompleteVariables"
+ | "autocompleteFunctions"
+ | "autocomplete"
+ | "extraExtensions"
+ | "forcedEnvironmentId"
+> &
+ Pick, "inputMode" | "onKeyDownCapture" | "step"> & {
+ onFocusRaw?: InputHTMLAttributes["onFocus"];
+ type?: "text" | "password" | "number";
+ hideObscureToggle?: boolean;
+ labelRightSlot?: ReactNode;
+ };
+
+export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
+ {
+ autoFocus,
+ autoSelect,
+ className,
+ containerClassName,
+ defaultValue,
+ disabled,
+ forceUpdateKey: forceUpdateKeyFromAbove,
+ help,
+ hideLabel,
+ hideObscureToggle,
+ label,
+ labelClassName,
+ labelPosition = "top",
+ labelRightSlot,
+ inputMode,
+ leftSlot,
+ name,
+ onBlur,
+ onChange,
+ onFocus,
+ onFocusRaw,
+ onKeyDownCapture,
+ onPaste,
+ placeholder,
+ required,
+ rightSlot,
+ size = "md",
+ step,
+ tint,
+ type = "text",
+ validate,
+ },
+ ref,
+) {
+ // Track a local key for updates. If the default value is changed when the input is not in focus,
+ // regenerate this to force the field to update.
+ const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
+ const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
+
+ const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
+ const [focused, setFocused] = useState(false);
+ const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]);
+ const textareaRef = useRef(null);
+ const inputRef = useRef(null);
+
+ useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
+ ref,
+ () => inputRef.current,
+ );
+
+ const handleFocus = useCallback(
+ (e: FocusEvent) => {
+ onFocusRaw?.(e);
+ setFocused(true);
+ if (autoSelect) {
+ inputRef.current?.select();
+ textareaRef.current?.select();
+ }
+ onFocus?.();
+ },
+ [autoSelect, onFocus, onFocusRaw],
+ );
+
+ const handleBlur = useCallback(() => {
+ setFocused(false);
+ onBlur?.();
+ }, [onBlur]);
+
+ // Force input to update when receiving change and not in focus
+ useLayoutEffect(() => {
+ const isFocused = document.activeElement === inputRef.current;
+ if (defaultValue != null && !isFocused) {
+ regenerateFocusedUpdateKey();
+ }
+ }, [regenerateFocusedUpdateKey, defaultValue]);
+
+ const id = useRef(`input-${generateId()}`);
+ const commonClassName = classNames(
+ className,
+ "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
+ "px-2 text-xs font-mono cursor-text",
+ );
+
+ const handleChange = useCallback(
+ (value: string) => {
+ onChange?.(value);
+ setHasChanged(true);
+ const isValid = (value: string) => {
+ if (required && !validateRequire(value)) return false;
+ if (typeof validate === "boolean") return validate;
+ if (typeof validate === "function" && !validate(value)) return false;
+ return true;
+ };
+ inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
+ },
+ [onChange, required, setHasChanged, validate],
+ );
+
+ const wrapperRef = useRef(null);
+
+ return (
+
+
+ {label}
+
+
+ {tint != null && (
+
+ )}
+ {leftSlot}
+
+ handleChange(e.target.value)}
+ onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
+ className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ required={required}
+ step={step}
+ placeholder={placeholder}
+ onKeyDownCapture={onKeyDownCapture}
+ />
+
+ {type === "password" && !hideObscureToggle && (
+ setObscured((o) => !o)}
+ />
+ )}
+ {rightSlot}
+
+
+ );
+});
+
+function validateRequire(v: string) {
+ return v.length > 0;
+}
diff --git a/apps/yaak-client/components/core/Prompt.tsx b/apps/yaak-client/components/core/Prompt.tsx
new file mode 100644
index 0000000..0c0d122
--- /dev/null
+++ b/apps/yaak-client/components/core/Prompt.tsx
@@ -0,0 +1,66 @@
+import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import type { FormEvent } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { generateId } from "../../lib/generateId";
+import { DynamicForm } from "../DynamicForm";
+import { Button } from "./Button";
+
+export interface PromptProps {
+ inputs: FormInput[];
+ onCancel: () => void;
+ onResult: (value: Record | null) => void;
+ confirmText?: string;
+ cancelText?: string;
+ onValuesChange?: (values: Record) => void;
+ onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
+}
+
+export function Prompt({
+ onCancel,
+ inputs: initialInputs,
+ onResult,
+ confirmText = "Confirm",
+ cancelText = "Cancel",
+ onValuesChange,
+ onInputsUpdated,
+}: PromptProps) {
+ const [value, setValue] = useState>({});
+ const [inputs, setInputs] = useState(initialInputs);
+ const handleSubmit = useCallback(
+ (e: FormEvent) => {
+ e.preventDefault();
+ onResult(value);
+ },
+ [onResult, value],
+ );
+
+ // Register callback for external input updates (from plugin dynamic resolution)
+ useEffect(() => {
+ onInputsUpdated?.(setInputs);
+ }, [onInputsUpdated]);
+
+ // Notify of value changes for dynamic resolution
+ useEffect(() => {
+ onValuesChange?.(value);
+ }, [value, onValuesChange]);
+
+ const id = `prompt.form.${useRef(generateId()).current}`;
+
+ return (
+
+
+
+
+ {cancelText || "Cancel"}
+
+
+ {confirmText || "Done"}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/RadioCards.tsx b/apps/yaak-client/components/core/RadioCards.tsx
new file mode 100644
index 0000000..c787642
--- /dev/null
+++ b/apps/yaak-client/components/core/RadioCards.tsx
@@ -0,0 +1,64 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+
+export interface RadioCardOption {
+ value: T;
+ label: ReactNode;
+ description?: ReactNode;
+}
+
+export interface RadioCardsProps {
+ value: T | null;
+ onChange: (value: T) => void;
+ options: RadioCardOption[];
+ name: string;
+}
+
+export function RadioCards({
+ value,
+ onChange,
+ options,
+ name,
+}: RadioCardsProps) {
+ return (
+
+ {options.map((option) => {
+ const selected = value === option.value;
+ return (
+
+ onChange(option.value)}
+ className="sr-only"
+ />
+
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/RadioDropdown.tsx b/apps/yaak-client/components/core/RadioDropdown.tsx
new file mode 100644
index 0000000..a1a642e
--- /dev/null
+++ b/apps/yaak-client/components/core/RadioDropdown.tsx
@@ -0,0 +1,69 @@
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+
+export type RadioDropdownItem =
+ | {
+ type?: "default";
+ label: ReactNode;
+ shortLabel?: ReactNode;
+ value: T;
+ rightSlot?: ReactNode;
+ }
+ | DropdownItemSeparator;
+
+export interface RadioDropdownProps {
+ value: T;
+ onChange: (value: T) => void;
+ itemsBefore?: DropdownItem[];
+ items: RadioDropdownItem[];
+ itemsAfter?: DropdownItem[];
+ children: DropdownProps["children"];
+}
+
+export function RadioDropdown({
+ value,
+ items,
+ itemsAfter,
+ itemsBefore,
+ onChange,
+ children,
+}: RadioDropdownProps) {
+ const dropdownItems = useMemo(
+ () => [
+ ...((itemsBefore
+ ? [
+ ...itemsBefore,
+ {
+ type: "separator",
+ hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
+ },
+ ]
+ : []) as DropdownItem[]),
+ ...items.map((item) => {
+ if (item.type === "separator") {
+ return item;
+ }
+ return {
+ key: item.value,
+ label: item.label,
+ rightSlot: item.rightSlot,
+ onSelect: () => onChange(item.value),
+ leftSlot: ,
+ } as DropdownItem;
+ }),
+ ...((itemsAfter
+ ? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
+ : []) as DropdownItem[]),
+ ],
+ [itemsBefore, items, itemsAfter, value, onChange],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx
new file mode 100644
index 0000000..aa9aca8
--- /dev/null
+++ b/apps/yaak-client/components/core/SegmentedControl.tsx
@@ -0,0 +1,136 @@
+import type { IconProps } from "@yaakapp-internal/ui";
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { type ReactNode, useRef } from "react";
+import { useStateWithDeps } from "../../hooks/useStateWithDeps";
+import { generateId } from "../../lib/generateId";
+import { Button } from "./Button";
+import { IconButton, type IconButtonProps } from "./IconButton";
+import { IconTooltip } from "./IconTooltip";
+import { Label } from "./Label";
+
+interface Props {
+ options: { value: T; label: string; icon?: IconProps["icon"] }[];
+ onChange: (value: T) => void;
+ value: T;
+ name: string;
+ size?: IconButtonProps["size"];
+ label: string;
+ className?: string;
+ hideLabel?: boolean;
+ labelClassName?: string;
+ help?: ReactNode;
+}
+
+export function SegmentedControl({
+ value,
+ onChange,
+ options,
+ size = "xs",
+ label,
+ hideLabel,
+ labelClassName,
+ help,
+ className,
+}: Props