Compare commits

...
Author SHA1 Message Date
Gregory SchierandGitHub 7e2db7799b Collapse encoded media in editable fields too (#534) 2026-08-13 12:56:32 -07:00
Gregory SchierandGitHub c596e25e5e Identify collapsed values and open them in a viewer (#533) 2026-08-13 12:26:35 -07:00
Gregory SchierandGitHub 74d1b5d6ce HUGE sidebar and typing performance improvements (#516) 2026-08-13 12:23:56 -07:00
WkdXeqtrandGitHub b9d4f76193 Add dropdown to pick header names and values
Adds a chevron on pair editor name and value inputs that opens the same options as inline autocomplete, so suggestions can be found by clicking instead of only by typing. Long values like User-Agent show a short label but insert the full string.

Row action menus move to a vertical ellipsis so the chevron only ever means "pick a value into this field".

Also restores the per-worktree Tauri config handling in run-dev.mjs, which a merge on the branch had reverted.
2026-08-13 10:58:20 -07:00
Gregory Schier d4a963e216 Pass per-worktree Tauri config to dev runs 2026-08-13 09:35:27 -07:00
be765d7bcc Fix nested template function depth tracking (#523)
Co-authored-by: Su <26924933+674019130@users.noreply.github.com>
2026-08-13 09:25:10 -07:00
Gregory Schier d43de3d95a Keep nested worktrees out of test runs
Test runs from the repo root were picking up every git worktree under .claude,
running 648 tests across 102 files instead of 322 across 34. That made results
misleading rather than just noisy: a failure in an unrelated worktree showed up
as a failure here.

Ignores the directory too. Only settings.local.json was listed, while rules.md
stays tracked.
2026-08-13 09:04:02 -07:00
Gregory Schier bc0494fe0e Keep template tag text colored in light themes
Tag text was black in light themes and correctly tinted in dark. Both came from
liftMax(), which pushes lightness to an extreme, and the two extremes are not
symmetric: lightening past the sRGB gamut clips to a still-tinted color, so dark
kept its hue by accident, while darkening to zero lightness collapses every hue
to black.

Stopping short of the extreme keeps the hue in both appearances with no branch
on appearance, and still clears 6:1 against the tag's own surface across every
accent. Dark shifts from washed out toward the accent, so a purple tag now reads
purple rather than near-white pink.
2026-08-13 08:57:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c57a64d455 Bump @hono/node-server from 1.19.14 to 2.1.0 (#531)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:24 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bebd74b09 Bump nanoid from 5.1.6 to 5.1.16 (#526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a6dd058a3d Bump ip-address and express-rate-limit (#525)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:41:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1fe8261fbd Bump hono from 4.12.27 to 4.12.34 (#524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:40:50 -07:00
Gregory SchierandGitHub 0e4c355e8b Collapse very long lines in response viewers (#532) 2026-08-13 08:40:04 -07:00
Gregory SchierandGitHub f6d926f4b9 Ignore bundled plugin directories left behind by past renames (#530) 2026-08-12 11:22:08 -07:00
Gregory SchierandGitHub a6be9dbaee Allow renaming URL path parameters from the Params tab (#528) 2026-08-12 10:17:02 -07:00
Gregory SchierandGitHub 67a628d67a Link date-fns format docs from timestamp.format (#529) 2026-08-12 09:50:36 -07:00
Gregory SchierandGitHub 0bf7eaed81 Correct timestamp.format help text to reference date-fns, not dayjs (#527) 2026-08-12 08:12:19 -07:00
784a3d3a32 fix(ci): align workflow Node.js version with package engines (#520)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:18:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
077bfa87b8 Bump body-parser from 2.2.2 to 2.3.0 (#512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:31:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
733f2e5929 Bump @hono/node-server from 1.19.14 to 2.0.10 (#513)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f994f89ac Bump hono from 4.12.25 to 4.12.27 (#514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24f76398f9 Bump postcss from 8.5.16 to 8.5.25 (#522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14475915df Bump shell-quote from 1.8.4 to 1.10.0 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a50e04f565 Bump seroval from 1.4.2 to 1.5.6 (#518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
32fbd66912 Bump fast-uri from 3.1.2 to 3.1.5 (#521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:52 -07:00
SuandGitHub 3503a9da8e Fix inline rename during IME composition (#519) 2026-08-01 07:45:36 -07:00
Gregory Schier cef6abf5d0 Handle OAuth callback connections concurrently so browser preconnects can't block login 2026-07-29 07:54:59 -07:00
Gregory Schier 3f098f95fe Increase import timeout from 5s to 60s 2026-07-24 07:36:44 -07:00
Gregory Schier 195f89337f Skip contribution policy check for bot PRs 2026-07-21 07:59:19 -07:00
Gregory Schier 57fffe5a41 Fix extracted text flashing on SSE stream updates 2026-07-21 07:59:19 -07:00
gschier 0c24d6562a Deploying to main from @ mountain-loop/yaak@b1ea3e3560 🚀 2026-07-17 15:59:44 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b1ea3e3560 Bump serde_with from 3.12.0 to 3.21.0 (#511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 22:35:58 -07:00
Gregory SchierandGitHub d72b7d7d30 Fix URL autocomplete replacement (#510) 2026-07-15 08:33:14 -07:00
Gregory SchierandGitHub b40e2cdc1b Fix keyboard shortcut help layout (#509) 2026-07-14 11:19:56 -07:00
Gregory SchierandGitHub 19cc6ee6d4 Batch streamed request body history into full-size chunks (#508) 2026-07-14 09:13:50 -07:00
Gregory SchierandGitHub e59ecce886 Fix Insomnia text body imports (#507) 2026-07-14 09:13:47 -07:00
Gregory SchierandGitHub c30f5b767b Close responses before request body history finishes (#498) 2026-07-14 08:55:40 -07:00
Gregory SchierandGitHub 42b22d4c07 Fall back to JWT exp claim when OAuth token response has no expires_in (#506) 2026-07-14 08:24:07 -07:00
Gregory SchierandGitHub e05feba708 Duplicate models from the DB instead of frontend snapshots (#505) 2026-07-14 07:43:14 -07:00
Gregory Schier 6b9b3660de Remap build paths in wasm output and regenerate for wasm-bindgen 0.2.121 2026-07-13 13:31:31 -07:00
Gregory SchierandGitHub e20a184c70 Scope Git watching and status to the sync directory (#503) 2026-07-09 15:26:37 -07:00
Gregory SchierandGitHub 155260521e Detect and surface plugin runtime crashes (#502) 2026-07-09 09:05:34 -07:00
Gregory SchierandGitHub 2fee5ba413 Use generated release notes placeholder (#501) 2026-07-06 09:54:38 -07:00
Gregory Schier 2c1cf5a13c Restrict privileged workflows to official repo 2026-07-05 09:41:14 -07:00
b332a0eba9 Fix startup failure from fd exhaustion when launched via Finder (#500)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:49:23 -07:00
Gregory SchierandClaude Fable 5 f2972ee534 Clarify PR template checkboxes that must always be checked
The "when reasonable" checkboxes read as optional, but the contribution
policy workflow requires all boxes checked. Reword them as either/or
statements and accept the legacy wording in the policy script so
existing PRs keep validating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:36:23 -07:00
Gregory SchierandClaude Fable 5 4cad671305 Remove release-notes command
The yaak.app MCP server carries the release workflow in its tool
descriptions and instructions; notes are generated from changelog
items on ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:56:58 -07:00
Gregory SchierandClaude Fable 5 ea33261e08 Update release docs for generated notes and version-locked CLI
The release command no longer teaches hand-writing GitHub notes: they
are generated from the yaak.app changelog items by release_ship_beta
and release_publish. AGENTS.md tag rule updated for the CLI being
version-locked to app tags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:55:21 -07:00
Gregory SchierandGitHub 4ee080fa49 Add in-app micro-feedback prompts (#497) 2026-07-04 23:21:53 -07:00
Gregory Schier c833aeba78 Fix commit dialog banner layout 2026-07-04 22:24:03 -07:00
Gregory SchierandGitHub eb2a2dd775 Convert request bodies when changing type (#499) 2026-07-04 22:22:35 -07:00
Gregory Schier e52853cc2d Always render commercial use banner placeholder 2026-07-04 14:44:49 -07:00
Gregory Schier 851d0a26f0 Enable release Tauri features in config 2026-07-04 14:09:41 -07:00
Gregory SchierandClaude Fable 5 78de83c754 Track @yaakapp/cli via the latest dist-tag
The CLI is now version-locked to app releases, so the devDependency
follows the latest stable instead of a range that would strand on the
old 0.x line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:54:19 -07:00
Gregory SchierandClaude Fable 5 9f3fd46d49 Publish CLI on app release tags (version-locked)
The CLI now publishes to npm on every v* tag at the app's version,
instead of its own yaak-cli-* tag namespace. Stables go to the latest
dist-tag, prereleases to beta/alpha. workflow_dispatch remains for
manual publishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:47:42 -07:00
Gregory Schier 6c42b27edb Build CEF Linux tarballs from debs 2026-07-03 17:35:56 -07:00
Gregory Schier 44ae19ff64 Bundle NSS modules in CEF AppImages 2026-07-03 16:23:24 -07:00
Gregory Schier 23bac3cff5 Build CEF Linux as experimental AppImage 2026-07-03 15:15:04 -07:00
Gregory SchierandGitHub cc05fec59d Add CEF runtime to Linux builds (#494) 2026-07-03 14:22:47 -07:00
Gregory Schier 1206d5889d Fix settings banner width 2026-07-03 14:21:31 -07:00
Gregory SchierandGitHub 273e9c184d Improve SQLite response persistence performance (#496) 2026-07-03 14:05:02 -07:00
Gregory Schier 3f9baca85e Respect manual contribution policy labels 2026-07-03 10:52:44 -07:00
Gregory SchierandGitHub 0497a54928 Custom Tauri window state plugin (#495) 2026-07-03 10:11:20 -07:00
Gregory Schier 5db2008fae Add Yaak changelog skill 2026-07-02 10:17:53 -07:00
Gregory SchierandGitHub 95ac3e310a Improve response history menu (#492) 2026-07-02 10:04:57 -07:00
Gregory SchierandGitHub 9b524e3dc7 Upgrade Tailwind to v4 (#491) 2026-07-02 09:53:22 -07:00
Gregory Schier bdf78254b5 Disable automatic Flathub workflow 2026-07-01 13:51:00 -07:00
Gregory Schier c5545c8781 Document release note contributor attribution 2026-07-01 13:39:05 -07:00
262 changed files with 9660 additions and 2409 deletions
@@ -1,50 +0,0 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
- A simple item that doesn't have a feedback or PR link
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".
@@ -19,10 +19,12 @@ Generate formatted markdown release notes for a Yaak tag.
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`)
- Contributor GitHub handles from `author.login`
- Plugin install links or other notable context
6. Format notes using Yaak style:
- Changelog badge at top
- Bulleted items with PR links where available
- Contributor handles for external PRs
- Feedback links where available
- Full changelog compare link at bottom
@@ -31,6 +33,7 @@ Generate formatted markdown release notes for a Yaak tag.
- Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence.
- Output the markdown code block last.
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
- Do not append `by @gschier` for PRs authored by `@gschier`.
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
+104
View File
@@ -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`.
@@ -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
+4 -2
View File
@@ -4,12 +4,14 @@
## Submission
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
- [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
- [ ] 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):
+60 -27
View File
@@ -10,8 +10,7 @@ const REVIEWER_LOGIN = "gschier";
const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
const MIN_AUTOMATIC_PR_NUMBER = 494;
const LABELS = {
inScope: {
@@ -54,16 +53,25 @@ 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:
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:
],
readContributing: [
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
],
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) {
@@ -103,8 +111,8 @@ function normalizeCheckboxLabel(label) {
.trim();
}
function checkboxState(body, label) {
const expectedLabel = normalizeCheckboxLabel(label);
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);
@@ -113,7 +121,7 @@ function checkboxState(body, label) {
continue;
}
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
return match[1].toLowerCase() === "x";
}
}
@@ -186,6 +194,18 @@ function analyzePullRequest(pr) {
};
}
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,
@@ -244,7 +264,8 @@ function analyzePullRequest(pr) {
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.",
message:
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
});
}
@@ -252,7 +273,7 @@ function analyzePullRequest(pr) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
});
}
}
@@ -428,10 +449,6 @@ function summarizeResult({ pr, analysis, skipped, skipReason }) {
};
}
function wasCreatedBefore(value, cutoff) {
return Date.parse(value) < Date.parse(cutoff);
}
async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true;
@@ -603,7 +620,7 @@ async function checkPullRequest({
repo,
pullNumber,
dryRun,
skipCreatedBefore,
minimumAutomaticPullNumber,
}) {
const response = await github.rest.pulls.get({
owner,
@@ -613,12 +630,9 @@ async function checkPullRequest({
const pr = response.data;
const issueNumber = pr.number;
if (
skipCreatedBefore != null &&
wasCreatedBefore(pr.created_at, skipCreatedBefore)
) {
if (pr.user.type === "Bot") {
core.notice(
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
);
return {
blocked: false,
@@ -626,7 +640,26 @@ async function checkPullRequest({
summary: summarizeResult({
pr,
skipped: true,
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
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,
};
@@ -756,8 +789,8 @@ async function run({ github, context, core }) {
context.eventName === "workflow_dispatch" &&
dryRunInput !== false &&
dryRunInput !== "false";
const skipCreatedBefore =
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
const minimumAutomaticPullNumber =
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
let pullNumbers;
if (payloadPr != null) {
@@ -795,7 +828,7 @@ async function run({ github, context, core }) {
repo,
pullNumber: pr.number,
dryRun,
skipCreatedBefore,
minimumAutomaticPullNumber,
}),
);
}
+1 -1
View File
@@ -30,4 +30,4 @@ jobs:
- name: Run JS Tests
run: vp test
- name: Run Rust Tests
run: cargo test --all
run: cargo test --all --features yaak-app-client/wry
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -1
View File
@@ -30,13 +30,14 @@ permissions:
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: ${{ github.event.pull_request.base.sha || github.ref }}
ref: main
fetch-depth: 1
- name: Check contribution policy
+11 -7
View File
@@ -1,17 +1,21 @@
name: Update Flathub
on:
release:
types: [published]
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
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps:
- name: Checkout app repo
uses: actions/checkout@v4
@@ -31,7 +35,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "24"
- name: Install source generators
run: |
@@ -39,7 +43,7 @@ jobs:
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo
- name: Commit and push to Flathub
working-directory: flathub-repo
@@ -48,5 +52,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}"
git commit -m "Update to ${{ inputs.tag }}"
git push
+1
View File
@@ -15,6 +15,7 @@ permissions:
jobs:
publish-npm:
if: github.repository == 'mountain-loop/yaak'
name: Publish @yaakapp/api
runs-on: ubuntu-latest
permissions:
+60 -11
View File
@@ -5,6 +5,7 @@ on:
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions:
contents: write
@@ -14,35 +15,59 @@ jobs:
matrix:
include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin"
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"
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: ""
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: ""
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: ""
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"
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: 40
@@ -66,11 +91,18 @@ jobs:
shared-key: ci
cache-on-failure: true
- name: Cache CEF (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
uses: actions/cache@v4
with:
path: ~/.cache/tauri-cef
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
- name: install dependencies (Linux only)
if: matrix.os == 'ubuntu'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
@@ -98,7 +130,7 @@ jobs:
- name: Run JS Tests
run: vp test
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
- name: Set version
run: npm run replace-version
@@ -150,13 +182,30 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseBody: "<!-- generated-by-yaak-releases -->"
releaseDraft: true
prerelease: true
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
args: "${{ matrix.args }}"
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
version="${GITHUB_REF_NAME#v}"
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -173,7 +222,7 @@ jobs:
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 ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
+4 -3
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
tags: [v*]
workflow_dispatch:
inputs:
version:
@@ -15,6 +15,7 @@ permissions:
jobs:
prepare-vendored-assets:
if: github.repository == 'mountain-loop/yaak'
name: Prepare vendored plugin assets
runs-on: ubuntu-latest
@@ -118,7 +119,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
@@ -175,7 +176,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
+1
View File
@@ -7,6 +7,7 @@ permissions:
contents: write
jobs:
deploy:
if: github.repository == 'mountain-loop/yaak'
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
+1
View File
@@ -58,3 +58,4 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
+1 -1
View File
@@ -1,2 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- 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
Generated
+1048 -126
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -31,6 +31,7 @@ members = [
"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",
]
@@ -47,9 +48,13 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
tauri = "2.11.1"
tauri = { version = "2.11.1", default-features = false, features = [
"common-controls-v6",
"compression",
"dynamic-acl",
] }
tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.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"
@@ -84,8 +89,13 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
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" }
+1 -1
View File
@@ -20,7 +20,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -10,7 +10,7 @@ export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
id: "folder-settings",
title: null,
size: "lg",
className: "h-[50rem]",
className: "h-200",
noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
});
@@ -38,7 +38,7 @@ export function BinaryFileEditor({
<VStack space={2}>
<SelectFile onChange={handleChange} filePath={filePath} />
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5">
<Banner className="mt-3 py-5!">
<div className="mb-4 text-center">
<div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request?
@@ -108,7 +108,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
rightSlot={
<IconButton
size="xs"
className="mr-0.5 !h-auto my-0.5"
className="mr-0.5 h-auto! my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
@@ -11,7 +11,7 @@ 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 flex-shrink-0",
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
);
if (onClick) {
@@ -439,7 +439,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
name="command"
label="Command"
placeholder="Search or type a command"
className="font-sans !text-base"
className="font-sans text-base!"
defaultValue={command}
onChange={handleSetCommand}
onKeyDownCapture={handleKeyDown}
@@ -448,7 +448,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
{filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full">
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
<Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center">
{g.label}
</Heading>
{g.items.map((v) => (
@@ -491,7 +491,7 @@ function CommandPaletteItem({
color="custom"
justify="start"
className={classNames(
"w-full h-sm flex items-center rounded px-1.5",
"w-full h-sm flex items-center rounded-sm px-1.5",
"hover:text-text",
active && "bg-surface-highlight",
!active && "text-text-subtle",
@@ -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 }) => (
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
<ContextMenu
items={items}
triggerPosition={triggerPosition}
triggerRect={triggerRect}
// The trigger isn't a React component here, so it arrives as an element and gets
// wrapped to look like the ref the menu expects
triggerRef={{ current: triggerEl ?? null }}
onClose={() => hideContextMenu(id)}
/>
</ErrorBoundary>
))}
</>
);
}
+5 -5
View File
@@ -155,7 +155,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
rightSlot={
filter.length > 0 && (
<IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
@@ -239,7 +239,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name}
</TableCell>
<TruncatedWideTableCell className="min-w-[10rem]">
<TruncatedWideTableCell className="min-w-40">
{c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
@@ -547,7 +547,7 @@ function CookieEditor({
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
}
function CookieTextInput({
@@ -589,7 +589,7 @@ function CookieTextarea({ onChange, value }: { onChange: (value: string) => void
<textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
className={classNames(cookieInputClassName, "min-h-20 resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
@@ -600,7 +600,7 @@ const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-none",
"border border-border-subtle outline-hidden",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
@@ -75,7 +75,7 @@ export function DnsOverridesEditor({ workspace }: Props) {
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
only for requests made from this workspace.
</div>
+2 -2
View File
@@ -23,8 +23,8 @@ export const DropMarker = memo(
<div
className={classNames(
"absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
)}
/>
</div>
+5 -5
View File
@@ -204,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}>
<DetailsBanner
summary={input.label}
className={classNames("!mb-auto", disabled && "opacity-disabled")}
className={classNames("mb-auto!", disabled && "opacity-disabled")}
>
<div className="mt-3">
<FormInputsStack
@@ -300,7 +300,7 @@ function TextArg({
onChange,
name: arg.name,
multiLine: arg.multiLine,
className: arg.multiLine ? "min-h-[4rem]" : undefined,
className: arg.multiLine ? "min-h-16" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional,
disabled: arg.disabled,
@@ -359,7 +359,7 @@ function EditorArg({
className={classNames(
"border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus",
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
!arg.rows && "max-h-40", // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
@@ -372,7 +372,7 @@ function EditorArg({
onChange={onChange}
hideGutter
heightMode="auto"
className="min-h-[3rem]"
className="min-h-12"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions}
@@ -392,7 +392,7 @@ function EditorArg({
id: "id",
size: "full",
title: arg.readOnly ? "View Value" : "Edit Value",
className: "!max-w-[50rem] !max-h-[60rem]",
className: "max-w-200! max-h-240!",
description: arg.label && (
<Label
htmlFor={id}
@@ -62,7 +62,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm"
className={classNames(
className,
"text !px-2 truncate",
"text px-2! truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)}
// If no environments, the button simply opens the dialog.
@@ -57,7 +57,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
resizeHandleClassName="-translate-x-[1px]"
resizeHandleClassName="-translate-x-px"
firstSlot={() => (
<EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null}
@@ -0,0 +1,76 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useRef, useState } from "react";
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
import { invokeCmd } from "../lib/tauri";
import { hideToastById, showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Input } from "./core/Input";
interface Props {
feature: FeedbackFeature;
onDone: () => void;
}
export function FeedbackToast({ feature, onDone }: Props) {
const [text, setText] = useState<string>("");
const [sent, setSent] = useState(false);
const sentRef = useRef(false);
const handleDismiss = () => {
onDone();
hideToastById(`feature-feedback-${feature}`);
};
const handleSend = () => {
const trimmedText = text.trim();
if (sentRef.current || trimmedText.length === 0) return;
sentRef.current = true;
setSent(true);
onDone();
// Fire-and-forget; failures are intentionally ignored
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
color: "success",
message: "Thanks for the feedback!",
});
};
return (
<VStack space={2}>
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
<div className="h-20">
<Input
size="xs"
// The editor forces its mono font on the scroller, so the override
// has to target it directly
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
label="Feedback"
hideLabel
stateKey={null}
multiLine
fullHeight
placeholder="Your thoughts..."
onChange={setText}
/>
</div>
<HStack space={1.5} justifyContent="end">
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
Dismiss
</Button>
<Button
size="xs"
color="primary"
disabled={sent || text.trim().length === 0}
onClick={handleSend}
>
Send
</Button>
</HStack>
</VStack>
);
}
+2 -2
View File
@@ -163,7 +163,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
<code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url}
</code>
{latestResponse ? (
@@ -190,7 +190,7 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
className={classNames(
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
"font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
)}
>
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
@@ -84,12 +84,12 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
@@ -97,7 +97,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)}
<span className="whitespace-nowrap" title={folder.name}>
{folder.name}
@@ -149,7 +149,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
className="opacity-70 text-primary!"
size="2xs"
iconSize="sm"
title="Copy folder ID"
+1 -1
View File
@@ -126,7 +126,7 @@ export function GrpcEditor({
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "!opacity-100")}>
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
@@ -1,4 +1,9 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }),
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
@@ -162,7 +169,7 @@ export function GrpcRequestPane({
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
)}
>
<UrlBar
@@ -201,7 +208,7 @@ export function GrpcRequestPane({
rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null}
className={classNames(
"font-mono text-editor min-w-[5rem] !ring-0",
"font-mono text-editor min-w-20 ring-0!",
paneWidth < 400 && "flex-1",
)}
>
@@ -259,7 +266,7 @@ export function GrpcRequestPane({
<Tabs
label="Request"
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
tabListClassName="mt-1 mb-1.5!"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
@@ -296,10 +303,10 @@ export function GrpcRequestPane({
hideLabel
forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0"
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
+31 -9
View File
@@ -1,14 +1,17 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
@@ -105,12 +108,21 @@ export function HeadersEditor({
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: "constant",
boost: 1, // Put above other completions
})) ?? [];
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
@@ -156,7 +156,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
title="Authentication Actions"
icon="settings"
size="xs"
className="!text-secondary"
className="text-secondary!"
/>
</Dropdown>
)}
@@ -57,7 +57,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
<GraphQLDocsExplorer
requestId={activeRequest.id}
schema={graphQLSchema}
className={classNames(orientation === "horizontal" && "!ml-0")}
className={classNames(orientation === "horizontal" && "ml-0!")}
style={style}
/>
)}
+56 -49
View File
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -16,10 +15,10 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
@@ -38,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
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 { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -75,15 +74,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -131,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("http_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
let numParams = 0;
if (
@@ -195,7 +204,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
});
};
const patch: Partial<HttpRequest> = { bodyType };
const patch: Partial<HttpRequest> = {
bodyType,
body: convertRequestBody({
body: activeRequest.body,
fromBodyType: activeRequest.bodyType,
toBodyType: bodyType,
}),
};
let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) {
newContentType = null;
@@ -265,28 +281,19 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
@@ -323,7 +330,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -346,7 +353,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
@@ -369,7 +376,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -377,7 +384,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
@@ -429,7 +436,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
@@ -456,7 +463,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0"
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
@@ -4,10 +4,12 @@ import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
@@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>(
() => [
@@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
},
},
{
@@ -135,12 +155,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
],
[
activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
mimeType,
responseEvents.data?.length,
saveResponse.mutate,
setViewMode,
viewMode,
timelineViewMode,
@@ -167,7 +193,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full flex-shrink-0",
"text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
@@ -180,7 +206,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)}
>
<HStack space={2} className="w-full flex-shrink-0">
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
@@ -194,7 +220,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
@@ -223,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm !flex-shrink max-w-full"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
@@ -236,7 +262,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : (
<span />
)}
<div className="justify-self-end flex-shrink-0">
<div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
@@ -249,7 +275,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
<Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error}
</Banner>
)}
@@ -73,14 +73,14 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="!opacity-100 !shadow">
<div key="settings" className="opacity-100! shadow!">
<Dropdown
onOpen={handleDropdownOpen}
items={
@@ -0,0 +1,274 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
/** The whole value, exactly as it reads in the document */
text: string;
sniffed: SniffedValue | null;
}
/**
* Shows a value the editor collapsed, in whatever viewer its type calls for.
*
* The editor only ever read the value's head, so this is where it is decoded in full — on an
* explicit click, behind a spinner, rather than during layout.
*/
export function LargeValueDialog({ text, sniffed }: Props) {
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
const decoded = useDecoded(text, viewer == null ? null : sniffed);
// Nothing recognised it, so there is nothing to decode it into
if (sniffed == null || viewer == null) {
return (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
*/
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
*
* It lives in the dialog's title rather than above the content, which would cost a band of
* whitespace the width of the dialog to hold one small button.
*/
function LargeValueActions({ text, sniffed }: Props) {
const items = useMemo(
() =>
largeValueActions({
value: () => text,
sniffed,
// The tag copies only what it hides; in here, what you see is the whole value
copyText: () => text,
}),
[text, sniffed],
);
return (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
/** Which viewer a media type calls for, or null if none of them can show it. */
function viewerFor(mime: string): Viewer | null {
if (mime.startsWith("image/svg")) return "svg";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
return null;
}
type Decoded = { bytes: Uint8Array } | { error: string };
/**
* The value's bytes, once they exist.
*
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
* during render — the dialog paints with a spinner first, instead of opening late.
*/
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
const [decoded, setDecoded] = useState<Decoded | null>(null);
useEffect(() => {
if (sniffed == null) return;
setDecoded(null);
let cancelled = false;
const timer = setTimeout(() => {
let result: Decoded;
try {
result = { bytes: decodeValue(text, sniffed) };
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
if (!cancelled) setDecoded(result);
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [text, sniffed]);
return decoded;
}
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
const PAGE_CHARS = 50_000;
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
const WRAP_CHARS = 120;
/**
* The raw text, in pages of short lines.
*
* What is left when nothing can render the value. Handing it to an editor whole would walk
* straight back into the layout stall that collapsed it in the first place, so it is paged and
* hard-wrapped instead: no line is ever long, and no page is ever big.
*/
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
const from = page * PAGE_CHARS;
const to = Math.min(from + PAGE_CHARS, text.length);
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
function wrap(text: string): string {
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= WRAP_CHARS) {
lines.push(line);
continue;
}
for (let i = 0; i < line.length; i += WRAP_CHARS) {
lines.push(line.slice(i, i + WRAP_CHARS));
}
}
return lines.join("\n");
}
export function showLargeValueDialog({ text, sniffed }: Props) {
showDialog({
id: "large-value",
size: "lg",
className: "h-[calc(100vh-10rem)]",
// Everything in one line: the same words the tag uses, and the same menu it carries. A
// separate description and a row of its own for the button cost three bands to say this.
title: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}
+1 -1
View File
@@ -59,7 +59,7 @@ function getDetail(
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
},
{
label: <div className="min-w-[12rem]">Renew License</div>,
label: <div className="min-w-48">Renew License</div>,
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor
hideGutter
wrapLines
className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")}
className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")}
language="markdown"
defaultValue={defaultValue}
onChange={onChange}
@@ -46,7 +46,7 @@ export function MarkdownEditor({
defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p>
) : (
<div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
<div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto">
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
</div>
);
@@ -539,7 +539,7 @@ function NumberUnitInput({
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48"
containerClassName="w-48!"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
@@ -66,7 +66,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
<Button
size="xs"
color="secondary"
className="mr-auto min-w-[5rem]"
className="mr-auto min-w-20"
onClick={async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
+7 -5
View File
@@ -1,3 +1,5 @@
@reference "../main.css";
.prose {
@apply text-text;
@@ -98,7 +100,7 @@
@apply text-notice hover:underline;
* {
@apply text-notice !important;
@apply text-notice!;
}
}
@@ -113,12 +115,12 @@
ol code,
ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded not-italic;
@apply px-1.5 py-0.5 rounded-sm not-italic;
@apply select-text;
}
pre {
@apply bg-surface-highlight text-text !important;
@apply bg-surface-highlight! text-text!;
@apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre;
@apply text-editor font-mono;
@@ -130,7 +132,7 @@
.banner {
@apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base;
&::before {
@apply block font-bold mb-1;
@@ -161,7 +163,7 @@
}
blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg;
p {
@apply m-0;
@@ -1,10 +1,17 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
interface Props {
@@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({
}: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return (
<Dropdown
@@ -36,16 +100,7 @@ export function RecentGrpcConnectionsDropdown({
disabled: connections.length === 0,
},
{ type: "separator", label: "History" },
...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
...connectionHistoryItems,
]}
>
<IconButton
@@ -1,13 +1,21 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { pluralize } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props {
responses: HttpResponse[];
@@ -22,32 +30,95 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId,
}: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a";
const saveResponse = useSaveResponse(activeResponse);
const copyResponse = useCopyHttpResponse(activeResponse);
const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
onPinnedResponseId(r.id);
},
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return (
<Dropdown
items={[
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Delete",
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse),
},
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{
label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id),
@@ -55,25 +126,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{ type: "separator", label: "History" },
{
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{ type: "separator" },
...responses.map((r: HttpResponse) => ({
type: "content",
hidden: dismissedMovedActions === true,
label: (
<HStack space={2}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtle">&rarr;</span>{" "}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span>
</HStack>
<DismissibleBanner
id={movedActionsBannerId}
color="info"
size="xs"
className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
})),
},
{
type: "separator",
label: "Recent",
},
...responseHistoryItems,
]}
>
<IconButton
@@ -1,10 +1,17 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
interface Props {
@@ -19,6 +26,63 @@ export function RecentWebsocketConnectionsDropdown({
onPinnedConnectionId,
}: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return (
<Dropdown
@@ -40,16 +104,7 @@ export function RecentWebsocketConnectionsDropdown({
disabled: connections.length === 0,
},
{ type: "separator", label: "History" },
...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
...connectionHistoryItems,
]}
>
<IconButton
@@ -167,7 +167,7 @@ export function ResponseCookies({ response }: Props) {
{cookie.value}
</span>
{cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
Deleted
</span>
)}
@@ -1,5 +1,6 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
@@ -29,12 +30,20 @@ export function ResponseHeaders({ response }: Props) {
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
title="Open in browser"
+1 -1
View File
@@ -24,7 +24,7 @@ export function ResponseInfo({ response }: Props) {
URL
<IconButton
iconSize="sm"
className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
title="Open in browser"
+1 -1
View File
@@ -10,7 +10,7 @@ export default function RouteError({ error }: { error: unknown }) {
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="w-[50rem] !h-auto">
<VStack space={5} className="w-200 h-auto!">
<Heading>Route Error 🔥</Heading>
<FormattedError>
{message}
+1 -1
View File
@@ -108,7 +108,7 @@ export function SelectFile({
"rtl mr-1.5",
inline && "w-full",
filePath && inline && "font-mono text-xs",
isHovering && "!border-notice",
isHovering && "border-notice!",
)}
color={isHovering ? "primary" : "secondary"}
onClick={handleClick}
@@ -93,7 +93,7 @@ export default function Settings({ hide }: Props) {
layout="horizontal"
defaultValue={mainTab || tabFromQuery}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
tabs={tabs.map(
(value): TabItem => ({
@@ -131,28 +131,28 @@ export default function Settings({ hide }: Props) {
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense />
</TabContent>
</Tabs>
@@ -56,7 +56,7 @@ export function SettingsGeneral() {
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="!w-full"
selectClassName="w-full!"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
@@ -112,11 +112,22 @@ export function SettingsGeneral() {
</SettingsSection>
</CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="p-4 max-w-xl mx-auto"
className="w-full p-4 max-w-xl mr-auto"
>
<p>
Workspace specific settings have moved to{" "}
@@ -341,7 +341,7 @@ function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps
}}
className={classNames(
"flex items-center justify-center",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
"border-border-subtle focus:border-border-focus",
)}
>
@@ -89,7 +89,7 @@ export function SettingsInterface() {
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="!w-72"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
@@ -106,7 +106,7 @@ export function SettingsInterface() {
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="!w-20"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
@@ -123,7 +123,7 @@ export function SettingsInterface() {
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="!w-72"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
@@ -139,7 +139,7 @@ export function SettingsInterface() {
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="!w-20"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
@@ -211,7 +211,7 @@ function PluginTableRow({
return (
<TableRow>
{showCheckbox && (
<TableCell className="!py-0">
<TableCell className="py-0!">
<Checkbox
hideLabel
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
@@ -249,7 +249,7 @@ function PluginTableRow({
)}
</HStack>
</TableCell>
<TableCell className="!py-0">
<TableCell className="py-0!">
<HStack justifyContent="end" space={1.5}>
{plugin != null && latestVersion != null ? (
<Button
@@ -56,7 +56,7 @@ export function SettingsProxy() {
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
selectClassName="!w-64"
selectClassName="w-64!"
/>
</SettingsSection>
@@ -99,7 +99,7 @@ export function SettingsProxy() {
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
inputWidthClassName="!w-96"
inputWidthClassName="w-96!"
onChange={(bypass) => patchProxy({ bypass })}
/>
</SettingsSection>
@@ -120,7 +120,7 @@ export function SettingsTheme() {
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
+12 -7
View File
@@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -588,7 +589,7 @@ function Sidebar({ className }: { className?: string }) {
rightSlot={
filterText.text && (
<IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={clearFilterText}
@@ -667,8 +668,8 @@ function Sidebar({ className }: { className?: string }) {
<div className="p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
wrapperClassName="h-auto! mb-auto"
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
>
<div>
No results, but found matches for{" "}
@@ -677,7 +678,7 @@ function Sidebar({ className }: { className?: string }) {
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
className="max-w-full rounded-sm align-middle focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
@@ -690,8 +691,8 @@ function Sidebar({ className }: { className?: string }) {
</EmptyStateText>
) : (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
wrapperClassName="h-auto! mb-auto"
className="h-auto! py-3 px-3 text-text-subtle! text-sm leading-relaxed text-center"
>
<div>
No results for{" "}
@@ -724,7 +725,11 @@ function Sidebar({ className }: { className?: string }) {
);
}
export default Sidebar;
// 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,
@@ -208,10 +208,10 @@ function InitializedTemplateFunctionDialog({
)}
/>
</HStack>
<div className="relative w-full max-h-[10rem]">
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest",
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
@@ -246,7 +246,7 @@ function InitializedTemplateFunctionDialog({
) : (
<span />
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
@@ -271,7 +271,7 @@ TemplateFunctionDialog.show = (
showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-[60rem]",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
+1 -1
View File
@@ -94,7 +94,7 @@ export const UrlBar = memo(function UrlBar({
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 !h-full"
className="w-8 mr-0.5 h-full!"
iconColor="secondary"
icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send"
@@ -1,15 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
pairs: HttpRequest["headers"];
pairs: EditablePair[];
stateKey: PairEditorProps["stateKey"];
onChange: (headers: HttpRequest["urlParameters"]) => void;
onChange: PairEditorProps["onChange"];
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
@@ -1,5 +1,10 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } 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";
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,17 +22,15 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
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 type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -54,15 +56,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// 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;
@@ -83,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// 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 tabs = useMemo<TabItem[]>(() => {
return [
@@ -127,23 +139,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => 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,
@@ -153,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
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,
@@ -165,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -217,7 +222,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
title="Close connection"
icon="x"
iconColor="secondary"
className="w-8 mr-0.5 !h-full"
className="w-8 mr-0.5 h-full!"
onClick={handleCancel}
/>
)
@@ -236,7 +241,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
tabListClassName="mt-1 mb-1.5!"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
@@ -249,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -257,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
@@ -269,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
@@ -283,10 +288,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
hideLabel
forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0"
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
@@ -294,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
/>
</div>
</TabContent>
+2 -2
View File
@@ -85,7 +85,7 @@ export function Workspace() {
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
@@ -162,7 +162,7 @@ function WorkspaceBody() {
// Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }}
>
<Banner color="warning" className="max-w-[30rem]">
<Banner color="warning" className="max-w-120">
The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink />
</Banner>
@@ -176,7 +176,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm"
className={classNames(
className,
"text !px-2 truncate",
"text px-2! truncate",
workspace === null && "italic opacity-disabled",
)}
{...buttonProps}
@@ -324,7 +324,7 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
<span className="text-xs font-mono **:cursor-auto **:select-text">
{show ? (
keyText.split("").map((c, i) => {
return (
@@ -127,7 +127,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
placeholder="Workspace Name"
label="Name"
defaultValue={workspace.name}
className="!text-base font-sans"
className="text-base! font-sans"
onChange={(name) => patchModel(workspace, { name })}
/>
@@ -161,7 +161,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{workspaceId}
<CopyIconButton
className="opacity-70 !text-primary"
className="opacity-70 text-primary!"
size="2xs"
iconSize="sm"
title="Copy workspace ID"
@@ -182,7 +182,7 @@ WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab)
showDialog({
id: "workspace-settings",
size: "lg",
className: "h-[calc(100vh-5rem)] !max-h-[50rem]",
className: "h-[calc(100vh-5rem)] max-h-200!",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
@@ -72,7 +72,7 @@ export function AutoScroller<T>({
size="sm"
iconSize="md"
variant="border"
className="!bg-surface z-10"
className="bg-surface! z-10"
onClick={() => setAutoScroll((v) => !v)}
/>
</div>
@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />}
<div
ref={containerRef}
className="h-full w-full overflow-y-auto focus:outline-none"
className="h-full w-full overflow-y-auto focus:outline-hidden"
onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined}
>
@@ -39,10 +39,10 @@ export function Checkbox({
<input
aria-hidden
className={classNames(
"appearance-none flex-shrink-0 border border-border",
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded outline-none ring-0",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
@@ -17,7 +17,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
<div className={className}>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
className="w-full!"
onChange={(color) => {
onChange(color);
regenerateKey(); // To force input to change
@@ -96,7 +96,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
<>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
className="w-full!"
onChange={(color) => {
onChange(color);
regenerateKey(); // To force input to change
@@ -18,7 +18,7 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
className={classNames(
className,
"flex items-center",
"opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono",
"opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
color == null && "border-border-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
@@ -42,7 +42,7 @@ export function DetailsBanner({
return (
<Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div
className={classNames(
"transition-transform",
+6 -6
View File
@@ -74,13 +74,13 @@ export function Dialog({
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-[10rem]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-[30rem]",
size === "md" && "w-[50rem]",
size === "lg" && "w-[70rem]",
size === "full" && "w-[100vw] h-[100vh]",
size === "dynamic" && "min-w-[20rem] max-w-[100vw]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
@@ -2,21 +2,26 @@ import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { MouseEvent } from "react";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
type DismissibleBannerSize = "sm" | "xs";
export function DismissibleBanner({
children,
className,
id,
size = "sm",
onDismiss,
onShow,
actions,
...props
}: BannerProps & {
id: string;
size?: DismissibleBannerSize;
onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
@@ -46,17 +51,36 @@ export function DismissibleBanner({
if (!shouldShow) return null;
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
const stopParentClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Banner className={classNames(className, "relative")} {...props}>
<Banner
className={classNames(
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
{...props}
>
<div className="@container">
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
<div
className={classNames(
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
)}
>
{children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => {
size={actionSize}
onClick={(event) => {
stopParentClick(event);
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
}}
@@ -69,8 +93,11 @@ export function DismissibleBanner({
key={a.label}
variant={a.variant ?? "border"}
color={a.color ?? props.color}
size="xs"
onClick={a.onClick}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label}
>
{a.label}
+45 -22
View File
@@ -233,23 +233,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
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<DOMRect, "top" | "bottom" | "left" | "right">;
/**
* 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<HTMLElement | null>;
className?: string;
items: DropdownProps["items"];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
{ triggerPosition, className, items, onClose },
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
ref,
) {
const triggerShape = useMemo(
() => ({
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
}),
[triggerPosition],
() =>
triggerRect ?? {
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
},
[triggerPosition, triggerRect],
);
if (triggerPosition == null) return null;
@@ -259,9 +276,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
isOpen={true} // Always open because we return null if not
className={className}
defaultSelectedIndex={null}
// A menu opened from an element points back at it. One opened at the cursor has nothing
// to point at, so it goes without.
showTriangle={triggerRect != null}
ref={ref}
items={items}
onClose={onClose}
triggerRef={triggerRef}
triggerShape={triggerShape}
/>
);
@@ -277,7 +298,9 @@ interface MenuProps {
fullWidth?: boolean;
isOpen: boolean;
items: DropdownItem[];
triggerRef?: RefObject<HTMLButtonElement | null>;
// 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<HTMLElement | null>;
isSubmenu?: boolean;
}
@@ -712,7 +735,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
className={classNames(
className,
"x-theme-menu",
"outline-none my-1 pointer-events-auto z-40",
"outline-hidden my-1 pointer-events-auto z-40",
"fixed",
)}
>
@@ -734,7 +757,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
{filter && (
<HStack
space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded-sm font-mono h-xs"
>
<Icon icon="search" size="xs" />
<div className="text">{filter}</div>
@@ -916,24 +939,24 @@ function MenuItem({
)
}
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left"
innerClassName="text-left!"
color="custom"
className={classNames(
className,
"h-xs", // More compact
"min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded",
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",
"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",
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}
>
<div className={classNames("truncate min-w-[5rem]")}>{item.label}</div>
<div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button>
);
}
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5;
@@ -9,7 +11,7 @@
@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 cursor-default;
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
}
}
@@ -19,21 +21,21 @@
.cm-changedLine {
/* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) {
@apply rounded-t;
@apply rounded-t-sm;
}
/* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) {
@apply rounded-b;
@apply rounded-b-sm;
}
}
/* Let content grow and disable individual scrolling for sync */
.cm-editor {
@apply h-auto relative !important;
@apply h-auto! relative!;
position: relative !important;
}
.cm-scroller {
@apply overflow-visible !important;
@apply overflow-visible!;
}
}
@@ -1,3 +1,5 @@
@reference "../../../main.css";
.cm-wrapper {
@apply h-full overflow-hidden;
@@ -7,7 +9,7 @@
/* Regular cursor */
.cm-cursor {
@apply border-text !important;
@apply border-text!;
/* Widen the cursor a bit */
@apply border-l-[2px];
}
@@ -15,8 +17,8 @@
/* Vim-mode cursor */
.cm-fat-cursor {
@apply outline-0 bg-text !important;
@apply text-surface !important;
@apply outline-0! bg-text!;
@apply text-surface!;
}
/* Matching bracket */
@@ -59,12 +61,12 @@
* {
@apply cursor-text;
@apply caret-transparent !important;
@apply caret-transparent!;
}
}
.cm-selectionBackground {
@apply bg-selection !important;
@apply bg-selection!;
}
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
@@ -88,7 +90,7 @@
}
.cm-gutter-lint {
@apply w-auto !important;
@apply w-auto!;
.cm-gutterElement {
@apply px-0;
@@ -111,7 +113,7 @@
@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 dark:shadow;
@apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
-webkit-text-security: none;
@@ -119,6 +121,24 @@
@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 {
@@ -162,7 +182,7 @@
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden !important;
@apply hidden!;
}
}
}
@@ -189,16 +209,16 @@
/* Style search matches */
.cm-searchMatch {
@apply bg-transparent !important;
@apply bg-transparent!;
@apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected {
@apply outline-text;
@apply bg-text !important;
@apply bg-text!;
&,
* {
@apply text-surface font-semibold !important;
@apply text-surface! font-semibold!;
}
}
}
@@ -223,8 +243,8 @@
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;
@apply cursor-default !important;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
@apply cursor-default!;
}
.cm-editor .fold-gutter-icon::after {
@@ -248,7 +268,7 @@
.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 !important;
@apply cursor-default!;
}
.cm-editor .cm-activeLineGutter {
@@ -277,7 +297,7 @@
}
.cm-tooltip-lint {
@apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important;
@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;
@@ -293,18 +313,18 @@
}
.cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
@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;
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
}
a {
@apply cursor-default !important;
@apply cursor-default!;
&::after {
@apply text-text bg-text h-3 w-3 ml-1;
@@ -319,10 +339,10 @@
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto;
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
& * {
@apply font-mono text-editor !important;
@apply font-mono! text-editor!;
}
.cm-completionIcon {
@@ -409,7 +429,7 @@
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
@apply text-sm flex items-center pb-0.5 shrink-0;
}
.cm-completionLabel {
@@ -427,7 +447,7 @@
input,
button {
@apply rounded-sm outline-none;
@apply rounded-sm outline-hidden;
}
button {
@@ -436,12 +456,12 @@
}
button[name="close"] {
@apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important;
@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-none;
@apply border outline-hidden;
}
input.cm-textfield {
@@ -8,6 +8,7 @@ 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";
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
@@ -33,6 +33,7 @@ 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";
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
@@ -288,7 +290,7 @@ function EditorInner({
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : 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;
@@ -381,6 +383,7 @@ function EditorInner({
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
@@ -410,7 +413,7 @@ function EditorInner({
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
@@ -430,7 +433,7 @@ function EditorInner({
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
@@ -486,7 +489,7 @@ function EditorInner({
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow",
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
);
if (format) {
@@ -639,7 +642,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorState(stateKey, update.state);
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
}
@@ -652,13 +655,53 @@ const placeholderElFromText = (text: string | undefined) => {
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<string, ReturnType<typeof debounce>>();
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 the hash of it instead.
// 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.
stateObj.docHash = md5(stateObj.doc);
// 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 {
@@ -668,7 +711,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
}
}
function getCachedEditorState(doc: string, stateKey: string | null) {
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== md5(doc)) {
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
@@ -59,6 +59,7 @@ 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";
@@ -258,8 +259,19 @@ export const baseExtensions = [
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(),
@@ -1,10 +1,14 @@
import type { CompletionContext } from "@codemirror/autocomplete";
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: GenericCompletionOption[];
options: GenericCompletion[];
}
/**
@@ -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":"<tag>"`
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
expect(doc[ranges[0]!.from - 1]).toBe('"');
expect(doc[ranges[0]!.to]).toBe('"');
// Everything after the value is still rendered, unlike a plain column cut
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
});
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
// machine. With a bigger body this falls back to the column cut, which is by design but
// makes the assertion depend on how fast the runner is.
test("keeps every key visible in a minified body with several large values", () => {
const chunk = "B".repeat(20_000);
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(5);
for (const key of ["a", "b", "c", "d", "e"]) {
// No collapse swallows the key
const at = doc.indexOf(`"${key}":`);
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
}
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
});
test("collapses a value on a pretty-printed line", () => {
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
// Only the long line is touched, and all that is left of it is ` "image": ""`
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
});
test("ignores tokens under the collapse threshold", () => {
// Under the threshold once the surrounding quotes are counted
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
// Nothing is big enough to collapse on its own, so the column rule takes over
const ranges = collapsedRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.to).toBe(doc.length);
});
});
describe("column collapsing, without a grammar", () => {
test("collapses everything past the limit", () => {
const ranges = collapsedRanges(plainState(BIG));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(BIG.length);
});
test("handles a long line of many short tokens", () => {
// A single-line CSV row: no token is long enough to collapse on its own
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
const ranges = collapsedRanges(plainState(row));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
});
test("collapses each long line independently", () => {
const doc = `${BIG}\nshort\n${BIG}`;
const ranges = collapsedRanges(plainState(doc));
expect(ranges).toHaveLength(2);
for (const length of visibleLineLengths(plainState(doc))) {
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
}
});
test("never hides a line break", () => {
const doc = `${BIG}\nshort`;
const state = plainState(doc);
for (const { from, to } of collapsedRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
expect(state.doc.lines).toBe(2);
});
});
describe("undelimited tokens", () => {
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
// line with nothing on it but a tag, so the column cut handles it instead.
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
test("falls back to the column cut for a long line of plain text", () => {
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
const ranges = collapsedRanges(textState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(doc.length);
});
test("leaves the start of the line readable", () => {
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
const state = textState(doc);
const ranges = collapsedRanges(state);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
});
});
describe("sniffing the collapsed value", () => {
/** The widget standing in for each collapse, in document order */
function widgets(state: EditorState) {
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
const iter = decorationsFor(state).iter();
while (iter.value != null) {
found.push(iter.value.spec.widget);
iter.next();
}
return found;
}
/** A base64 value that starts with a PNG signature and runs on well past the limits */
function pngBase64(length = 1_000_000) {
const bytes = new Uint8Array(length);
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("names the format of a base64 value inside JSON", () => {
const state = jsonState(`{"image":"${pngBase64()}"}`);
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("names the format of a data URI", () => {
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
});
test("names nothing when the value is just text", () => {
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
});
test("reads a column cut from the start of its line, not from the cut", () => {
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
// belongs to begins at the start of the line, which is where the signature is
const state = plainState(pngBase64());
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(0);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("reads a token collapse from the value itself", () => {
const doc = `{"image":"${pngBase64()}"}`;
const [widget] = widgets(jsonState(doc));
// Just past the opening quote, so the signature is the first thing it sees
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("finds a run that starts partway through a line, and names it", () => {
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
// so the prefix stays on screen and the blob is still named.
const prefix = "some prefix text ";
const state = plainState(`${prefix}${pngBase64()}`);
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(prefix.length);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
});
describe("recomputing", () => {
test("updates when the document changes", () => {
const state = plainState('{"image":"short"}');
expect(collapsedRanges(state)).toEqual([]);
const next = state.update({
changes: { from: 0, to: state.doc.length, insert: BIG },
}).state;
expect(collapsedRanges(next)).toHaveLength(1);
});
});
describe("media collapsing, below the length rules", () => {
/** A real PNG signature, at a size that no length rule would touch */
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
/** Well under every length threshold, so only the media rule can collapse it */
const SMALL = png(3_000);
test("collapses a small image on a short line", () => {
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
});
test("names it, so the tag can say what it is", () => {
const state = jsonState(`{"avatar":"${SMALL}"}`);
const iter = decorationsFor(state).iter();
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("leaves a value of the same size alone when nothing recognises it", () => {
// The only difference from the case above is what the bytes turn out to be
const prose = "x".repeat(SMALL.length);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("leaves anything under the media threshold alone, recognised or not", () => {
const tiny = png(400);
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
});
test("leaves text alone even when its first bytes match a signature", () => {
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
// enough that the sniff alone must not be allowed to hide something.
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("still collapses it once it is long enough to be a rendering problem", () => {
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
});
test("collapses a data URI on a short line", () => {
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
expect(collapsedRanges(state)).toHaveLength(1);
});
test("still leaves an ordinary document completely untouched", () => {
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
"\n",
);
expect(collapsedRanges(jsonState(doc))).toEqual([]);
});
});
describe("media rule only, for a document being edited", () => {
/** What an editable editor gets: rule 2 alone */
function editableRanges(state: EditorState) {
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
return ranges;
}
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image, the one thing it is for", () => {
const value = png(3_000);
const state = jsonState(`{\n "avatar": "${value}"\n}`);
expect(editableRanges(state)).toHaveLength(1);
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
value,
);
});
test("leaves a long value alone when nothing can name it", () => {
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
});
test("never cuts at the column limit, which would strand text out of reach", () => {
// Rule 3 territory: a minified body with no value big enough to collapse on its own
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
// The read-only rules still cut it
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
});
test("never swallows a template tag, collapsing only the run beside it", () => {
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
// on screen where it can still be read and edited
const tag = "{{ image }}";
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
});
test("finds a data URI whole, header and all", () => {
const value = `data:image/jpeg;base64,${png(3_000)}`;
const doc = `{"avatar":"${value}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
test("hides no line break, so the plugin may provide it from the viewport", () => {
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
for (const { from, to } of editableRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
});
});
describe("templated fields, where the grammar is an overlay", () => {
// Every editable field mixes its language with twig, which mounts the base language as an
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
// case that has to keep working: an image pasted into a JSON request body.
const twigState = (doc: string) =>
EditorState.create({
doc,
extensions: [
twig({
base: jsonc(),
environmentVariables: [],
completionOptions: [],
onClickVariable: () => {},
onClickMissingVariable: () => {},
onClickPathParameter: () => {},
extraExtensions: [],
}),
largeValues,
],
});
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image in a templated body", () => {
const value = png(3_000);
const state = twigState(`{\n "avatar": "${value}"\n}`);
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
});
describe("viewport scoping", () => {
const doc = () => {
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
};
test("decorates only the ranges it is given", () => {
const state = jsonState(doc());
const secondLine = state.doc.line(3);
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
expect(all.size).toBe(3);
expect(one.size).toBe(1);
});
test("covers a line the range only partly overlaps", () => {
// The viewport can start mid-line; the collapse still has to span the whole value
const state = jsonState(doc());
const line = state.doc.line(2);
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
expect(partial.size).toBe(1);
const iter = partial.iter();
expect(iter.to).toBeGreaterThan(line.from + 6);
});
});
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
/**
* Marks the tag as the thing that opens the menu.
*
* The whole tag is the target rather than the chevron alone: there is only one action, and a
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
* itself. The chevron stays as the affordance that says so.
*
* A span with a role rather than a real button, because a button's box model makes the line
* taller — the one thing this extension exists to keep from happening.
*/
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
el.role = "button";
el.ariaHasPopup = "menu";
el.tabIndex = 0;
el.title = title;
el.ariaLabel = title;
// Keep the editor from moving the cursor when the tag is pressed
el.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onOpen();
});
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
onOpen();
});
}
class LargeValueWidget extends WidgetType {
constructor(
private readonly from: number,
private readonly to: number,
private readonly valueFrom: number,
private readonly sniffed: SniffedValue | null,
) {
super();
}
eq(other: LargeValueWidget) {
return (
other.from === this.from &&
other.to === this.to &&
other.valueFrom === this.valueFrom &&
other.sniffed?.mime === this.sniffed?.mime
);
}
toDOM(view: EditorView) {
const length = this.to - this.from;
const el = document.createElement("span");
// The same neutral tag styling a path parameter uses. The theme scope matters: these
// tokens resolve against the tag palette, not the editor's ambient one, where a
// tag-sized border is meant to be near invisible.
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
if (thumbnail != null) {
el.appendChild(thumbnail);
}
const label = document.createElement("span");
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
// Without a name there is nothing to show but the fact that something is missing.
label.textContent =
this.sniffed == null
? `${formatSize(length)} hidden…`
: `${this.sniffed.label} · ${formatSize(length)}`;
el.appendChild(label);
const chevron = document.createElement("span");
chevron.className = "tag-action";
chevron.ariaHidden = "true";
chevron.innerHTML = CHEVRON_DOWN;
el.appendChild(chevron);
makeTagButton(
el,
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
() => fireAndForget(this.openMenu(view, el)),
);
return el;
}
/**
* Copy, view and save, in a menu opened against the button.
*
* Everything the menu needs is pulled in on click. The editor loads on every response, and
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
* asks for them.
*/
private async openMenu(view: EditorView, tag: HTMLElement) {
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
await Promise.all([
import("../../../lib/contextMenu"),
import("../../LargeValueDialog"),
import("../../../lib/largeValue"),
]);
const { sniffed } = this;
// Sliced when an action runs rather than now, so opening the menu never touches the value
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
const head = view.state.sliceDoc(
this.valueFrom,
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
);
// The whole tag, so the menu can align to whichever edge has room beside it
const rect = tag.getBoundingClientRect();
toggleContextMenu({
id: "large-value",
triggerPosition: { x: rect.left, y: rect.bottom },
triggerRect: rect,
triggerEl: tag,
items: [
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
...largeValueActions({
value,
sniffed,
// Exactly what the tag stands in for, which under the column rule is only the tail
copyText: () => view.state.sliceDoc(this.from, this.to),
onView: () => showLargeValueDialog({ text: value(), sniffed }),
}),
],
});
}
ignoreEvent() {
return false;
}
}
/**
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
*
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
* that previews some images and not others is worse than one that previews none. Only widgets in
* the viewport are ever built, so a screenful is the most that decode at once.
*/
const THUMBNAIL_MAX_CHARS = 4_000_000;
/**
* A preview of the value, at a size fixed before it loads.
*
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
* the line's height or the tag's width. A growing line box is the layout cost this whole
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
*
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
* thing that must not happen here.
*/
function buildThumbnail(
view: EditorView,
valueFrom: number,
to: number,
sniffed: SniffedValue | null,
): HTMLElement | null {
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
if (sniffed.encoding !== "base64") return null;
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
const img = document.createElement("img");
img.className = "tag-thumbnail";
img.alt = "";
img.ariaHidden = "true";
img.decoding = "async";
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
// broken-image glyph sitting in the tag.
img.addEventListener("error", () => img.remove());
// Read the document and build the data URI after the widget is measured and on screen
requestAnimationFrame(() => {
if (!img.isConnected) return;
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
img.src = `data:${sniffed.mime};base64,${payload}`;
});
return img;
}
interface Line {
from: number;
to: number;
}
/**
* The lines the given ranges touch, in document order and each listed once.
*
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
*/
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
const lines: Line[] = [];
let lastFrom = -1;
for (const range of ranges) {
let pos = range.from;
for (;;) {
const line = state.doc.lineAt(pos);
if (line.from > lastFrom) {
lastFrom = line.from;
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
lines.push({ from: line.from, to: line.to });
}
}
if (line.to >= range.to) break;
pos = line.to + 1;
}
}
return lines;
}
/**
* How long to spend parsing before falling back to the column rule.
*
* The initial parse is budgeted by time, so it stops partway through a document with several
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
* 12 ms, against the 171 ms of layout it saves.
*/
const PARSE_TIMEOUT_MS = 100;
/**
* The parsed tree covering these lines.
*
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
* inside it is what saves the frame. For everything else we take whatever the background parse
* has reached and decorate again when it advances — a forced parse runs from the top of the
* document, so making one happen on every scroll would cost far more than the tags are worth.
*/
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
const last = lines[lines.length - 1];
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
if (last == null || !stalls) {
return syntaxTree(state);
}
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
}
const QUOTES = ['"', "'", "`"];
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
/** The header that turns a bare run into a data URI, when one sits right before it. */
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
/**
* Encoded media on this line, found by reading the text rather than the grammar.
*
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
* inside it. Rule 2 has to work in those fields above all, since that is where images get
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
* the alphabet does.
*
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
* as one thing and its declared type is what names it.
*/
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
const text = state.sliceDoc(line.from, line.to);
const collapses: Collapse[] = [];
ENCODED_RUN.lastIndex = 0;
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
let start = m.index;
// A data URI's own header breaks the alphabet, so the run starts after it
const header = DATA_URI_HEAD.exec(text.slice(0, start));
if (header != null) {
start = header.index;
}
const valueFrom = line.from + start;
const valueTo = line.from + m.index + m[0].length;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
const sniffed = sniffValue(head);
// Only hide what we can name. A short magic number matches by chance now and then, so the
// run itself has to look encoded too.
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
}
}
return collapses;
}
/**
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
*
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
* nobody is editing — which is also the only place this rule applies.
*/
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
const collapses: Collapse[] = [];
tree.iterate({
from: line.from,
to: line.to,
enter: (node) => {
// A node this small can't contain anything worth collapsing, and neither can its children
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
// Only leaves, so we collapse the string itself rather than the object holding it
if (node.node.firstChild != null) return true;
const from = Math.max(node.from, line.from);
const to = Math.min(node.to, line.to);
const open = state.sliceDoc(from, from + 1);
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
if (!quoted) return false;
// Everything inside the quotes. The whole value is hidden, so it is its own value range
const valueFrom = from + 1;
const valueTo = to - 1;
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
return false;
},
});
return collapses;
}
/**
* Both rules' findings as one list, in document order, with nothing overlapping.
*
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
* error. The token wins where they collide, because its range stops at the quotes and so leaves
* the structure around it readable.
*/
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
const merged = [...tokens];
for (const run of runs) {
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
merged.push(run);
}
}
return merged.sort((a, b) => a.from - b.from);
}
/**
* Where the line runs past the column limit, counting only what is still visible after the
* token collapses, or -1 if it fits.
*/
function findColumnCut(line: Line, tokens: Collapse[]): number {
let visible = 0;
let pos = line.from;
for (const token of [...tokens, null]) {
const segmentEnd = token == null ? line.to : token.from;
if (segmentEnd > pos) {
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
}
visible += segmentEnd - pos;
}
if (token != null) {
pos = token.to;
}
}
return -1;
}
function collapsesForLine(
state: EditorState,
tree: SyntaxTree,
line: Line,
rules: CollapseRules,
): Collapse[] {
const runs = findMediaRuns(state, line);
// Length alone only hides things on a line that would stall without it, and never in a
// document being edited, where hiding text by size would put it out of reach
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
// Short enough to render, so there is nothing left to cut
if (!stalls) {
return tokens;
}
const cut = findColumnCut(line, tokens);
if (cut < 0) {
return tokens;
}
// The cut always lands in a visible stretch, so it never splits a token collapse
const kept = tokens.filter((t) => t.to <= cut);
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
// body that is nothing but one base64 blob is still recognisable from there
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
return kept;
}
/**
* The collapses covering the given document ranges.
*
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
* view and no DOM.
*/
export function collapseDecorations(
state: EditorState,
ranges: readonly { from: number; to: number }[],
rules: CollapseRules = "all",
): DecorationSet {
const lines = collapsibleLines(state, ranges);
if (lines.length === 0) {
return Decoration.none;
}
const tree = treeForLines(state, lines);
const decorations: Range<Decoration>[] = [];
for (const line of lines) {
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
decorations.push(Decoration.replace({ widget }).range(from, to));
}
}
return Decoration.set(decorations);
}
/**
* Scoped to the viewport, so the cost is a screenful however big the document is.
*
* Recomputed when the document changes, when the viewport moves, and when background parsing
* advances — a value can only be found once the grammar has reached it.
*/
function collapsePlugin(rules: CollapseRules) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.decorations = collapseDecorations(
update.view.state,
update.view.visibleRanges,
rules,
);
}
}
},
{
decorations: (v) => v.decorations,
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
provide: (plugin) =>
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
},
);
}
/** All three rules. For a document nobody is editing. */
export const largeValues: Extension = [collapsePlugin("all")];
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
export const mediaValues: Extension = [collapsePlugin("media")];
@@ -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("<svg/>")}`;
expect(sniffValue(uri)).toEqual({
mime: "image/svg+xml",
label: "SVG",
offset: "data:image/svg+xml;base64,".length,
encoding: "base64",
});
});
test("points past the header, so the payload can be decoded from there", () => {
const payload = base64Of(PNG);
const uri = `data:image/png;base64,${payload}`;
const sniffed = sniffValue(uri)!;
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
});
test("falls back to the bytes when the declared type says nothing", () => {
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
});
test("handles a header with no type at all", () => {
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
});
test("handles extra parameters", () => {
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
});
test("handles a percent-encoded payload", () => {
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
mime: "text/html",
label: "HTML",
offset: "data:text/html,".length,
encoding: "percent",
});
});
test("defaults a bare percent-encoded payload to text", () => {
expect(sniffValue("data:,hello%20there")).toMatchObject({
mime: "text/plain",
encoding: "percent",
});
});
});
describe("decoding only the head", () => {
test("reads no more of the value than the bytes asked for", () => {
const big = base64Of(PNG, 3_000_000);
// Truncating to the head must not change what is found, which is the whole point:
// callers only ever hand it a slice
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
});
test("decodes whole 4-character groups only", () => {
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
});
test("declines a value too short to hold a group", () => {
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
});
test("declines text outside the alphabet", () => {
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
});
});
describe("labels", () => {
test.each([
["image/png", "PNG"],
["image/svg+xml", "SVG"],
["text/plain", "TEXT"],
["application/vnd.ms-excel", "MS-EXCEL"],
["image/x-icon", "ICON"],
["application/octet-stream", "APPLICATION"],
["font/woff2", "WOFF2"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
])("%s reads as %s", (mime, label) => {
expect(labelForMime(mime)).toBe(label);
});
});
@@ -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:[<mime>][;<param>…],` — the payload follows the comma. */
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
interface Signature {
label: string;
mime: string;
offset?: number;
/** Byte values, where `null` matches anything */
magic: readonly (number | null)[];
}
/**
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
*
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
* is one byte, and splitting into code points would be the wrong unit for that.
*/
function ascii(pattern: string): (number | null)[] {
const bytes: (number | null)[] = [];
for (let i = 0; i < pattern.length; i++) {
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
}
return bytes;
}
const SIGNATURES: readonly Signature[] = [
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
];
/**
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
* than in the signature itself. Anything unlisted is some flavour of MP4.
*/
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
avif: { mime: "image/avif", label: "AVIF" },
avis: { mime: "image/avif", label: "AVIF" },
heic: { mime: "image/heic", label: "HEIC" },
heix: { mime: "image/heic", label: "HEIC" },
hevc: { mime: "image/heic", label: "HEIC" },
mif1: { mime: "image/heif", label: "HEIF" },
msf1: { mime: "image/heif", label: "HEIF" },
"M4A ": { mime: "audio/mp4", label: "M4A" },
"qt ": { mime: "video/quicktime", label: "MOV" },
};
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
if (bytes.length < offset + magic.length) return false;
return magic.every((b, i) => b == null || bytes[offset + i] === b);
}
function readAscii(bytes: Uint8Array, from: number, length: number): string {
return String.fromCharCode(...bytes.subarray(from, from + length));
}
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
if (readAscii(bytes, 4, 4) === "ftyp") {
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
}
const sig = SIGNATURES.find((s) => matches(bytes, s));
return sig == null ? null : { mime: sig.mime, label: sig.label };
}
/**
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
*
* Only the characters those bytes need are decoded. The trailing partial group is dropped
* rather than padded, since `atob` rejects a group of one or two characters outright and we
* have no use for the byte a three-character group would add.
*/
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
const wanted = Math.ceil(bytes / 3) * 4;
let b64 = text.slice(offset, offset + wanted);
b64 = b64.slice(0, b64.length - (b64.length % 4));
if (b64.length === 0) return null;
// The URL-safe alphabet stands for the same bytes
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
try {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
} catch {
return null;
}
}
/**
* What a value is, from its head alone, or null if nothing recognises it.
*
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
* ignored. The offsets in the result are relative to the start of the whole value.
*/
export function sniffValue(head: string): SniffedValue | null {
const lead = head.length - head.trimStart().length;
const value = head.slice(lead);
const dataUri = DATA_URI.exec(value);
if (dataUri != null) {
const declared = dataUri[1]!.toLowerCase();
const offset = lead + dataUri[0].length;
if (!dataUri[2]!.split(";").includes("base64")) {
// A percent-encoded payload states its own type or is text by definition
const mime = declared === "" ? "text/plain" : declared;
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
}
if (!UNINFORMATIVE.includes(declared)) {
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
}
return sniffBase64At(head, offset);
}
return sniffBase64At(head, lead);
}
function sniffBase64At(head: string, offset: number): SniffedValue | null {
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
const sniffed = bytes == null ? null : sniffBytes(bytes);
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
}
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
const ENCODED_RUN_CHARS = 64;
/**
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
*
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
* up about once in every 65,000 values. That was harmless while a value had to be long enough
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
* prose leaves the alphabet within a few characters, and an encoded blob never does.
*/
export function isEncodedRun(head: string, offset: number): boolean {
const run = head.slice(offset);
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
}
/** Subtypes whose own name makes a poor label */
const LABEL_OVERRIDES: Record<string, string> = {
"text/plain": "TEXT",
};
/**
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
* as MS-EXCEL. Anything still too long falls back to the top-level type.
*/
export function labelForMime(mime: string): string {
const override = LABEL_OVERRIDES[mime];
if (override != null) {
return override;
}
const [type, subtype] = mime.split("/");
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
return word.toUpperCase();
}
return (type ?? mime).toUpperCase();
}
@@ -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();
}
@@ -1,9 +1,51 @@
import { genericCompletion } from "../genericCompletion";
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";
export const completions = genericCompletion({
options: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});
const protocolOptions: GenericCompletionOption[] = [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
];
export function getUrlCompletionConfig(
options: GenericCompletionOption[],
minMatch = 3,
): GenericCompletionConfig {
const urlOptions = [
...protocolOptions,
...options.filter(
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
),
];
return {
minMatch,
options: urlOptions.map<GenericCompletion>((option) => ({
...option,
apply: applyUrlCompletion,
})),
};
}
export function applyUrlCompletion(
view: EditorView,
completion: Completion,
from: number,
to: number,
) {
const isProtocol = /^https?:\/\/$/.test(completion.label);
const replaceTo = isProtocol
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
: view.state.doc.length;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, replaceTo),
annotations: pickedCompletion.of(completion),
});
}
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
@@ -24,8 +24,8 @@ export function EventViewerRow({
onClick={onClick}
className={classNames(
"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left",
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded",
isActive && "bg-surface-active !text-text",
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-hidden focus:text-text rounded-sm",
isActive && "bg-surface-active text-text!",
"text-text-subtle hover:text",
)}
>
+1 -1
View File
@@ -30,7 +30,7 @@ export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
className={classNames(
className,
variant === "with-bg" &&
"rounded bg-surface-highlight px-1 border border-border text-text-subtle",
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest",
)}
>
@@ -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 : <span>{action}</span>,
}));
vi.mock("./HotkeyLabel", () => ({
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
}));
describe("HotkeyList", () => {
test("keeps a grid cell for actions without a shortcut", () => {
const markup = renderToStaticMarkup(
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
);
expect(markup).toContain(
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
);
});
});
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotkeyLabel className="truncate" action={hotkey} />
<Hotkey className="ml-4" action={hotkey} />
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
<div className="ml-4">
<Hotkey action={hotkey} />
</div>
</Fragment>
))}
{bottomSlot}
@@ -81,7 +81,7 @@ export function HttpMethodTagRaw({
colored && m === "PATCH" && "text-notice",
colored && m === "POST" && "text-success",
colored && m === "DELETE" && "text-danger",
"font-mono flex-shrink-0 whitespace-pre",
"font-mono shrink-0 whitespace-pre",
"pt-[0.15em]", // Fix for monospace font not vertically centering
)}
>
@@ -31,7 +31,7 @@ export function HttpResponseDurationTag({ response }: Props) {
);
}
function formatMillis(ms: number) {
export function formatMillis(ms: number) {
if (ms < 1000) {
return `${ms} ms`;
}
+69 -5
View File
@@ -22,6 +22,7 @@ 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";
@@ -63,6 +64,13 @@ export type InputProps = Pick<
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"];
@@ -115,6 +123,7 @@ function BaseInput({
readOnly,
required,
rightSlot,
showOptionsPicker,
size = "md",
stateKey,
tint,
@@ -156,6 +165,17 @@ function BaseInput({
[],
);
// 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;
@@ -201,7 +221,7 @@ function BaseInput({
const id = useRef(`input-${generateId()}`);
const editorClassName = classNames(
className,
"!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder",
"bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder",
);
const isValid = useMemo(() => {
@@ -264,7 +284,7 @@ function BaseInput({
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
!isValid && hasChanged && "!border-danger",
!isValid && hasChanged && "border-danger!",
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs",
@@ -318,6 +338,7 @@ function BaseInput({
editorClassName,
multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)}
onFocus={handleFocus}
onBlur={handleBlur}
@@ -333,7 +354,7 @@ function BaseInput({
: `Obscure ${typeof label === "string" ? label : "field"}`
}
size="xs"
className={classNames("mr-0.5 !h-auto my-0.5", disabled && "opacity-disabled")}
className={classNames("mr-0.5 h-auto! my-0.5", disabled && "opacity-disabled")}
color={tint}
// iconClassName={classNames(
// tint === 'primary' && 'text-primary',
@@ -349,12 +370,55 @@ function BaseInput({
onClick={() => setObscured((o) => !o)}
/>
)}
{pickerOptions.length > 0 && (
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
)}
{rightSlot}
</HStack>
</div>
);
}
/**
* 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<DropdownItem[]>(
() =>
options.map((o) => ({
label: o.label,
onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
})),
[options, onSelect],
);
return (
<Dropdown items={items}>
<IconButton
tabIndex={-1}
iconSize="sm"
size="xs"
icon="chevron_down"
title="Show suggestions"
className="mr-0.5 h-auto! my-0.5 text-text-subtlest @max-[24rem]:hidden"
/>
</Dropdown>
);
}
function validateRequire(v: string) {
return v.length > 0;
}
@@ -548,9 +612,9 @@ function EncryptionInput({
color={tint}
aria-label="Configure encryption"
className={classNames(
"flex items-center justify-center !h-full !px-1",
"flex items-center justify-center h-full! px-1!",
"opacity-70", // Makes it a bit subtler
props.disabled && "!opacity-disabled",
props.disabled && "opacity-disabled!",
)}
>
<HStack space={0.5}>
@@ -73,7 +73,7 @@ export function KeyValueRow({
<>
<td
className={classNames(
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
"select-none py-0.5 pr-2 h-full max-w-40",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName,
@@ -86,12 +86,12 @@ export function KeyValueRow({
</td>
<td
className={classNames(
"select-none py-0.5 break-all max-w-[15rem]",
"select-none py-0.5 break-all max-w-60",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
<div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />}
{children}
{resolvedRightSlot ? (
+1 -1
View File
@@ -27,7 +27,7 @@ export function Label({
className={classNames(
className,
visuallyHidden && "sr-only",
"flex-shrink-0 text-sm",
"shrink-0 text-sm",
"text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5",
)}
{...props}

Some files were not shown because too many files have changed in this diff Show More