Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5.5 349e6f9197 Fix proxy passthrough on Windows (#779)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:58:29 -07:00
Gregory Schier 1248112fe0 Smooth splitter dragging (#765) 2026-10-21 12:22:45 -07:00
Zhizhen He 8a85fb3dcd Preserve literal form field names when converting to JSON
Use an object with a null prototype so inherited property names are not
mistaken for existing fields and __proto__ remains a literal JSON key.
Cover __proto__, constructor, and toString with regression cases.
2026-10-16 01:42:28 +08:00
Zhizhen HeandGregory Schier 277bbe1baa Prevent stale sync confirmations from deleting or overwriting newer data (#739)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-10-15 09:10:33 -07:00
Gregory Schieranddependabot[bot] 62f76c5c36 Bump the npm production group (25 updates) and dedupe the lockfile (#760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:49:15 -07:00
dependabot[bot] 98cf213b9e chore(deps): bump the cargo group across 1 directory with 6 updates (#758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:13:22 -07:00
dependabot[bot] c5662a2851 chore(deps): bump motion from 13.5.0 to 14.0.0 (#744)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:40 -07:00
dependabot[bot] d534e88c26 chore(deps-dev): bump the npm-development group with 3 updates (#742)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:34 -07:00
dependabot[bot] ae354c3261 chore(deps): bump zstd from 0.13.3 to 0.14.0 (#749)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:27 -07:00
dependabot[bot] 26cbd45f76 chore(deps): bump tower-http from 0.6.10 to 0.7.1 (#748)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:21 -07:00
dependabot[bot] c81e8ffd5c chore(deps): bump yasna from 0.5.2 to 0.6.0 (#747)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:13 -07:00
dependabot[bot] 29d54cda0d chore(deps): bump csscolorparser from 0.7.2 to 0.9.0 (#746)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:06 -07:00
Gregory Schier b01c9c34c7 Add Claude Code and Codex plugin manifests and MCP registry metadata (#756) 2026-10-15 07:14:22 -07:00
Gregory Schier 9b3715024d Move response filters into Rust (#755) 2026-10-15 06:22:23 -07:00
Gregory Schier 19908b1902 Add a Connect an agent section with setup for Claude Code, Codex, Cursor, and VS Code 2026-10-14 22:16:55 -07:00
Gregory Schier 72b43f7110 Move the MCP server into Rust, hosted by the app and yaak mcp (#752) 2026-10-14 21:51:40 -07:00
Gregory Schier b6c1f700f1 Proxy viewer extraction: response viewers into packages/ui (#754) 2026-10-14 21:32:19 -07:00
Gregory Schier b5508e6bb1 Proxy storage: flows table, bodies in blobs.sqlite, settings (#751) 2026-10-14 13:50:26 -07:00
Gregory Schier 694c0272cd Proxy engine: persistent CA, capped capture, upgrade tunnel (#750) 2026-10-14 13:10:51 -07:00
Gregory Schier 602c6e1083 Bump @yaak/mcp-server to 0.2.2 2026-10-14 09:33:55 -07:00
dependabot[bot] 80a707ccc0 chore(deps): bump fast-uri from 3.1.7 to 3.1.8 (#731)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 11:20:30 -07:00
dependabot[bot] 9d59577dcd chore(deps): bump jsonpath-plus from 10.4.0 to 11.0.0 (#717)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:32 -07:00
dependabot[bot] 290fdc3f93 chore(deps): bump proxy-addr from 2.0.7 to 2.0.8 (#736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:10 -07:00
dependabot[bot] c03b483fde chore(deps-dev): bump shell-quote from 1.10.0 to 1.12.0 (#735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:00 -07:00
dependabot[bot] 2c097eba2e chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 (#734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:49 -07:00
dependabot[bot] 5664f9b0bb chore(deps): bump brace-expansion (#732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:32 -07:00
dependabot[bot] ce2064cab6 chore(deps): bump ip-address from 10.7.0 to 10.7.3 (#729)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:21 -07:00
dependabot[bot] 80bf2240f8 chore(deps-dev): bump the npm-development group across 1 directory with 4 updates (#726)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:10 -07:00
dependabot[bot] 23fb222957 chore(deps): bump the npm-production group across 1 directory with 19 updates (#727)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:58 -07:00
dependabot[bot] c29a62ba6f chore(deps): bump rustls-platform-verifier from 0.6.2 to 0.7.1 (#719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:30 -07:00
dependabot[bot] f8b1785866 chore(deps): bump dirs from 6.0.0 to 7.0.0 (#720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:16 -07:00
dependabot[bot] d2f14c65b3 chore(deps): bump base64 from 0.22.1 to 0.23.1 (#721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:03 -07:00
dependabot[bot] f467e22f3f chore(deps): bump dark-light from 2.0.0 to 3.0.0 (#722)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:53 -07:00
dependabot[bot] 0ca5fc1e2d chore(deps): bump xxhash-rust from 0.8.15 to 0.8.19 (#737)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:44 -07:00
dependabot[bot] 9bda8cea1e chore(deps): bump the cargo group across 1 directory with 10 updates (#738)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:30 -07:00
Zhizhen He 919e58fc9d Prevent sync data loss from incomplete tracked files (#725)
Co-authored-by: Zhizhen He <hezhizhen.yi@gmail.com>
2026-10-06 21:45:26 -07:00
Gregory Schier b4417add33 Fix PDF viewer crash on older macOS WebKit (#730) 2026-10-06 20:50:07 -07:00
M kashif 7890281db3 Trim leading/trailing whitespace from request URLs (#724) 2026-10-06 20:50:03 -07:00
Ngo Quoc Viet d44d75dcef fix(template-function-xml): return attribute and text() values from XPath (#723) 2026-10-06 20:31:36 -07:00
Gregory Schier 7f30253616 Select new sub-environments and keep enabled state through bulk edit (#714) 2026-09-26 09:40:33 -07:00
Gregory Schier 6c2a18d506 Attach CodeMirror editors after building them to batch layout work (#713) 2026-09-26 08:54:30 -07:00
Gregory Schier 0a57d8eb61 Convert the remaining Insomnia tags to native Yaak functions (#712) 2026-09-25 10:50:22 -07:00
Gregory Schier 411aa262c7 Test the plugin's copy of the template tag scanner (#711) 2026-09-25 10:06:11 -07:00
Gregory Schier 34815f327a Fix Insomnia template and GraphQL import fidelity (#693) 2026-09-25 09:58:31 -07:00
Gregory Schier 6fc43a60e2 Make template tag scanners quote-aware (#710) 2026-09-25 09:32:59 -07:00
Gregory Schier 23d369d84e Print template strings in plain quotes unless they can't round-trip (#708) 2026-09-25 09:07:28 -07:00
dependabot[bot]andGregory Schier 6e7f23a778 chore(deps-dev): bump @yaakapp/cli from 0.5.1 to 2026.8.0 (#698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:37 -07:00
dependabot[bot]andGregory Schier b03c41b767 chore(deps): bump git2 from 0.20.4 to 0.21.0 (#703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:33 -07:00
dependabot[bot]andGregory Schier a0d71eb486 chore(deps-dev): bump the npm-development group across 1 directory with 3 updates (#695)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:26 -07:00
dependabot[bot]andGregory Schier 38c562f775 chore(deps): bump whatwg-mimetype and @types/whatwg-mimetype (#697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:06:53 -07:00
dependabot[bot] 1249d15f2c chore(deps): bump cocoa from 0.26.1 to 0.27.0 (#702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:30 -07:00
dependabot[bot] 1707c387ea chore(deps): bump tokio-tungstenite from 0.26.2 to 0.30.0 (#701)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:23 -07:00
dependabot[bot] a497d9f018 chore(deps): bump brotli from 7.0.0 to 8.0.1 (#700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:16 -07:00
dependabot[bot] c13e26656b chore(deps): bump the cargo group with 4 updates (#699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:08 -07:00
dependabot[bot] dfc974db95 chore(deps): bump focus-trap-react from 11.0.6 to 12.0.3 (#696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:01 -07:00
dependabot[bot] 0b86d3ca3f chore(deps): bump the npm-production group across 1 directory with 10 updates (#707)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:41:54 -07:00
Gregory Schier 9e51e95f9e Polish the import source list
The URL link and menu item stay enabled and focus the field on repeat
clicks, waiting out the menu's focus return. Larger empty-state icon,
inline links inherit the text color with a translucent underline, and
card rows use a muted trash icon for removal.
2026-09-24 15:40:58 -07:00
Gregory Schier aa10c44a45 Add Bruno imports and a file-tree importer API (#706) 2026-09-24 14:50:44 -07:00
Gregory Schier 46464cfacf Switch to the imported workspace after a deep link import 2026-09-24 08:01:58 -07:00
Gregory Schier 263a215f7a Fix sidebar ordering for items with equal sort priorities (#705) 2026-09-23 08:00:08 -07:00
Gregory Schier 646d969649 Fix PDF preview worker initialization (#704) 2026-09-23 07:41:55 -07:00
Gregory Schier 4913711968 Fix autocomplete for incomplete template tags (#692) 2026-09-18 09:16:36 -07:00
Gregory Schier 35d4c9d79d Allow six hours for release builds and uploads (#691) 2026-09-17 11:41:35 -07:00
Gregory Schier bbe8d1eef5 fix: retry release uploads and retain artifacts for seven days (#690) 2026-09-17 10:11:23 -07:00
Gregory Schier 1e3dfff01e Fix JSON breadcrumb filters for literal keys (#689) 2026-09-17 08:18:15 -07:00
dependabot[bot] fccff5b7db chore(deps): bump motion from 12.43.0 to 13.2.0 (#683)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 07:24:13 -07:00
Vlad Solomon 6ed25b9f42 JSON breadcrumbs for the response viewer (YK-724) (#653) 2026-09-17 06:42:32 -07:00
dependabot[bot] 4a4d3b4301 chore(deps): bump react-pdf from 10.5.0 to 11.0.0 (#687)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:02:04 -07:00
dependabot[bot] 25a464546a chore(deps): bump lucide-react from 0.525.0 to 1.45.0 (#688)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:01:52 -07:00
Gregory Schier 2c51fc92d6 Create the example workspace in the browser build 2026-09-15 16:32:20 -07:00
Gregory SchierandClaude Opus 5 f827aae46d Share one exporter, and flush the file before saying it is written (#686)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:56:23 -07:00
Gregory SchierandClaude Fable 5 caeebebc75 Create a workspace in the environment test fixtures
Fresh databases stopped getting a default workspace with the home screen
(#660), so the fixtures from #654 that took the first workspace panicked
on an empty list. They now create one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 14:50:05 -07:00
Gregory Schier 621f9c3b8f Add an OG image and link preview tags to the web build 2026-09-15 14:48:07 -07:00
Gregory SchierandClaude Opus 5 77b18d3477 Export a workspace without needing a filesystem (#685)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:29:37 -07:00
Gregory Schier a8c845b091 Add a dev menu item to show the home screen 2026-09-15 14:27:11 -07:00
dependabot[bot] bcbf6d32a7 chore(deps): bump zod from 3.25.76 to 4.6.2 (#672)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:25:16 -07:00
dependabot[bot] e30b289ed7 chore(deps): bump nanoid from 5.1.16 to 6.0.1 (#671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:25:08 -07:00
Gregory Schier 85904566e7 chore(plugins): replace deprecated zip-extract with zip 2026-09-15 14:24:13 -07:00
Gregory Schier 0b6803c6c0 fix(editor): don't flag an empty JSON editor as invalid 2026-09-15 14:16:34 -07:00
Gregory Schier edeb433c79 Point the example workspace at yaak.run (#684) 2026-09-15 14:16:13 -07:00
dependabot[bot] 12e6e8a977 chore(deps): bump sha1 from 0.10.6 to 0.11.0 (#674)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:07:04 -07:00
Ricardo Lopes Temoteo ab616fd915 Fix MCP send_http_request ignoring environmentId (#654) 2026-09-15 14:04:38 -07:00
Gregory SchierandClaude Opus 5 4859aba142 Add a way for a host to save bytes (#678)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:01:47 -07:00
Gregory Schier cef569ee15 Add the yaak.run playground API (#681) 2026-09-15 13:55:36 -07:00
Gregory SchierandClaude Opus 5 ae518dfb4c Convert Postman's simple dynamic variables to template functions (#680)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:48:27 -07:00
Gregory SchierandClaude Opus 5 c9990d8492 Stop queueing build tooling majors (#679)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:48:08 -07:00
Ngo Quoc Viet 309818200a fix(template-function-uuid): use the current time when uuid.v6 has no timestamp (#647) 2026-09-15 13:35:13 -07:00
Gregory Schier 06142d007a Replace the default workspace with a home screen (#660) 2026-09-15 13:33:57 -07:00
Gregory SchierandClaude Opus 5 8a6e4810fd Listen on HOST and PORT instead of a bind address (#666)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:27:07 -07:00
Gregory SchierandClaude Opus 5 5a793e1d42 Clear the inherited non-blocking flag on accepted sockets in the CLI test server (#668)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:22:01 -07:00
Gregory SchierandClaude Opus 5 3d6c31440c Work through the open Dependabot PRs (#663)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:18:54 -07:00
Gregory SchierandClaude Opus 5 31d55440c1 Import Digest, NTLM, OAuth 1.0, and folder-level auth from Postman collections (#667)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:18:34 -07:00
Gregory SchierandClaude Opus 5 a822cc93d4 Make the web dev server work from another machine (#665)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:29:07 -07:00
Gregory SchierandClaude Opus 5 48889b6550 Add scripts for running Yaak in a browser (#664)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:12:53 -07:00
Gregory Schier 881cff472d Show a web proxy startup message by default 2026-09-14 22:44:58 -07:00
Gregory Schier d465c5679a Make web proxy request logging opt-in 2026-09-14 22:38:07 -07:00
Gregory Schier 0a68dea133 Remove hosted embedding setup from the self-hosting README 2026-09-14 22:24:50 -07:00
Gregory Schier 17a44f34a3 Focus the confirmation action when opening dialogs 2026-09-14 22:21:43 -07:00
Gregory Schier 8c8be31aa2 Expose the web server as an embeddable Axum router 2026-09-14 22:21:43 -07:00
Gregory Schier ff5db718ce Confirm proxy use before the first web request (#657) 2026-09-14 21:29:07 -07:00
Gregory Schier fd95b4cba3 Fix responses with more than 100 headers failing on HTTP/1 (#658) 2026-09-14 21:04:47 -07:00
Gregory Schier 7c660f4528 Correct the language table in the HTTP Snippet README (#656) 2026-09-14 13:41:33 -07:00
Gregory Schier 0337ef35c9 Describe the request template functions (#655) 2026-09-14 12:38:21 -07:00
Gregory SchierandClaude Fable 5 602bd1bb45 Move Install under the screenshot and License to the end of the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 11:03:59 -07:00
Gregory SchierandClaude Fable 5 57ab349901 Add install and license sections to the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 11:02:35 -07:00
Gregory SchierandClaude Opus 5 7a6d275fdb feat(auth-oauth2): custom parameters for authorization, token, and refresh requests (#629)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:25 -07:00
Gregory Schier 7d0926c961 fix(input): opt plain inputs out of macOS Writing Tools suggestions (#646) 2026-09-13 09:15:15 -07:00
Gregory SchierandClaude Opus 5 522b898620 feat(auth): add HTTP Digest authentication plugin (#628)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:06 -07:00
Gregory SchierandClaude Fable 5.1 cb7ed9264d feat(client): show an indicator on the Info tab when a request has a description (#645)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:12:42 -07:00
Gregory SchierandClaude Fable 5.1 90cb578e26 Route all database writes through one connection (#642)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 08:12:41 -07:00
Gregory SchierandClaude Fable 5.1 ff7eebf3cd fix(importer-curl): join line continuations that have trailing whitespace (#644)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:33:54 -07:00
Ngo Quoc Viet eff0c06cb0 fix(importer-postman): stop swapping the API key name and its value (#643) 2026-09-12 12:52:20 -07:00
dependabot[bot] c6e09a5af3 Bump vite-plus from 0.2.9 to 0.3.0 in the npm-development group (#632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:56 -07:00
dependabot[bot] b05071ef4b Bump cpy-cli from 5.0.0 to 7.0.0 (#633)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:40 -07:00
dependabot[bot] a44f31eeb2 Bump cpx2 from 8.0.2 to 9.0.0 (#634)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:26 -07:00
dependabot[bot] 1103d05915 Bump zip from 4.0.0 to 7.0.0 (#637)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:15 -07:00
Gregory SchierandClaude Fable 5.1 2f5639a26f chore: cargo fmt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:35:33 -07:00
Gregory SchierandClaude Opus 5 7f8b7bf567 feat(import): remember the user's import selection (#619)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:26:43 -07:00
Gregory Schier 096e83a7bd chore(deps): patch open security advisories across npm deps (#630) 2026-09-10 14:34:00 -07:00
gschier 89ce84cefe Deploying to main from @ mountain-loop/yaak@d5d2a64c32 🚀 2026-09-10 15:37:06 +00:00
Gregory Schier d5d2a64c32 fix(cli): focus the agent skill description on when to use it (#625) 2026-09-09 08:50:26 -07:00
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
Gregory SchierandClaude Fable 5 50cccf1d25 fix(auth-oauth1): sign with the token secret when no access token is set (#611)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:40 -07:00
Gregory SchierandClaude Fable 5 0aae516f3a feat(importer-openapi): fill the OAuth redirect URI from an environment variable (#610)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:36 -07:00
Gregory SchierandClaude Fable 5 b7b8ae5f94 feat(settings): add HTTP version as an inherited request setting (#609)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:01:02 -07:00
Gregory SchierandClaude Fable 5 6777003b70 fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:43:54 -07:00
Gregory SchierandClaude Fable 5 426c6d5eb6 fix(plugins): render template function config values before plugins see them (#608)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:19:15 -07:00
Ngo Quoc Viet 068afe325e fix(importer-curl): keep an = inside --url-query and form values (#606) 2026-08-24 21:14:37 -07:00
Ngo Quoc Viet cef1129d4b fix(template-function-json): escape control characters in json.escape (#607) 2026-08-24 21:14:14 -07:00
Ngo Quoc Viet 9a7bcf73bb fix(auth-oauth1): use the signing key as the PLAINTEXT signature (#605) 2026-08-24 21:13:57 -07:00
Gregory SchierandClaude Opus 5 aa76d501f0 fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:04:26 -07:00
Gregory Schier ce8cb5e7bc fix(environments): avoid opening dropdown with editor 2026-08-20 11:14:36 -07:00
Gregory Schier 66c13d7e13 Port OpenAPI improvements from the integration branch (#602) 2026-08-20 08:42:42 -07:00
Ngo Quoc Viet 388dd8815f fix(copy-as): emit shell-safe arguments in the curl and gRPCurl exporters (#593) 2026-08-20 08:41:47 -07:00
Ngo Quoc Viet cb295b7102 fix(importer-curl): keep --data-urlencode whole and survive a stray percent (#597) 2026-08-20 08:41:03 -07:00
Gregory Schier 4e835cf7e8 Add OpenAPI import round-trip harness (#600) 2026-08-20 07:44:15 -07:00
Gregory Schier 2350689d7a Import OpenAPI security the way Yaak inherits it (#601) 2026-08-20 07:39:18 -07:00
Gregory Schier 4eebd606ef Fix OpenAPI importer issues found by spec review (#599) 2026-08-20 07:16:44 -07:00
Gregory Schier c4f96f3f11 Create environments for OpenAPI server URLs (#590) 2026-08-19 12:45:32 -07:00
Gregory Schier 71c217d3f0 Populate OAuth credential variables during OpenAPI import (#587) 2026-08-19 08:43:17 -07:00
Gregory Schier d89831a84d Preserve OpenAPI security requirement semantics (#586) 2026-08-19 08:35:12 -07:00
Gregory Schier 3332ae263f Align OpenAPI 3.2 URL expectations with base URL variables (#594) 2026-08-19 07:42:54 -07:00
Gregory Schier df1fd864b2 Support OpenAPI 3.2 QUERY and additional operations (#591) 2026-08-19 07:10:32 -07:00
Gregory Schier a2d54ca774 Preserve base URL variable when OpenAPI servers are omitted (#585) 2026-08-19 07:07:18 -07:00
Gregory Schier 36fec8b005 Render all columns in irregular CSV responses (#584) 2026-08-18 20:24:57 -07:00
Gregory Schier 115615d994 Let the web image workflow publish a version by hand
`latest` could only ever come from a release tag, and tagging also fires the
app and CLI releases. A dispatch that names a version now publishes it and
latest, which is how the first image gets out before a release exists.
2026-08-18 11:48:59 -07:00
Gregory Schier 538f782068 Serve the web client from yaak-web (#582) 2026-08-18 10:39:17 -07:00
Gregory Schier 3f202ff664 Fix macOS relaunch activation (#583) 2026-08-18 09:56:40 -07:00
Ngo Quoc Viet 131b7e5ab1 fix(importer-curl): read combined short flags as separate options (#577) 2026-08-18 09:09:04 -07:00
Gregory Schier 69083918f9 Add the browser send proxy and web sender (#572) 2026-08-18 08:28:01 -07:00
Gregory Schier 4b2dcf9a1a Open Settings in a dialog on the web build (#581) 2026-08-17 19:07:25 -07:00
Gregory Schier 569f552d79 Stop sending scope on the authorization code token request (#579) 2026-08-17 12:23:08 -07:00
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00
Gregory Schier 7ca772347f Sweep orphaned response bodies at browser startup (#576) 2026-08-17 10:38:52 -07:00
Gregory Schier b89c448345 Fix cookie defaults and Timeline ordering, and give models a real Default (#573) 2026-08-17 09:14:17 -07:00
Gregory Schier 2021df112a Implement response deletes in the browser host instead of declining them (#574) 2026-08-17 09:02:18 -07:00
Gregory Schier 2d2a390bfd Use clang-18 from apt.llvm.org for the wasm build on 22.04 runners
clang-15 got past the C23 [[noreturn]] error but still fails compiling
sqlite-wasm-rs for wasm32: its stdint.h falls through to host glibc headers
(bits/libc-header-start.h not found). clang-18 handles wasm32 as freestanding
and compiles it (it is what ubuntu-24.04 uses). 22.04's repos stop at clang-15,
so install 18 from apt.llvm.org. Runners stay on 22.04 to keep the glibc floor.
2026-08-16 22:54:34 -07:00
Gregory Schier 5cce23566a Allow manual worktree setup 2026-08-16 22:32:33 -07:00
Gregory Schier e99f6d2bc7 Gate the titlebar inset on a windowChrome capability instead of osType (#570) 2026-08-16 22:21:38 -07:00
Gregory Schier bea58b16b4 Force text presentation for the Enter hotkey symbol (#569) 2026-08-16 22:21:04 -07:00
Gregory Schier 93fba4d9b4 Restore ubuntu-22.04 release runners; use clang-15 for the wasm build (#568) 2026-08-16 21:52:56 -07:00
Gregory Schier b9071eafe0 Revert "Guard app releases against missing artifacts"
This reverts commit 778c74c635.
2026-08-16 18:19:47 -07:00
Gregory Schier 778c74c635 Guard app releases against missing artifacts 2026-08-16 17:30:10 -07:00
Gregory Schier 0f434361a7 Fix Linux WASM release builds 2026-08-16 15:41:00 -07:00
Gregory Schier d27d11af7c Move plugin actions and authentication onto PluginHost (#563) 2026-08-16 11:41:03 -07:00
Gregory Schier 1a19a06a23 Add native OpenAPI importer (#486) 2026-08-16 11:40:34 -07:00
Gregory Schier 07a9a6c6c0 Update NTLM auth tests for the new send() response shape (#567) 2026-08-16 11:35:17 -07:00
Gregory Schier e54240d579 Let plugins declare assets to place beside the bundle (#565) 2026-08-16 11:22:43 -07:00
Gregory Schier 8bca013ab4 Fix plugin runtime build and JSON linter crash (#566) 2026-08-16 11:22:02 -07:00
Gregory Schier 10e962a0e6 Add a plugin API for reading HTTP response bodies (#560) 2026-08-16 11:10:14 -07:00
Gregory Schier 78954e10c8 Fix 23 Dependabot alerts (#562) 2026-08-16 10:27:28 -07:00
Gregory Schier 9eb7a001da Move template rendering and themes onto PluginHost (#559) 2026-08-16 09:22:46 -07:00
Gregory Schier 6a02cbe525 Add a Host trait and move DB/model commands off Tauri (#558) 2026-08-16 08:41:35 -07:00
Gregory Schier 6f91f76064 Run the desktop's model layer in the browser (#557) 2026-08-16 07:34:48 -07:00
Gregory Schier 32e92d484b Let yaak-models compile for wasm32-unknown-unknown (#556) 2026-08-15 14:55:54 -07:00
Gregory Schier cdbbef34f8 Fix native TLS client certificates on Linux (#554) 2026-08-15 11:36:36 -07:00
Gregory SchierandClaude Fable 5 4838353585 Move the RPC wire schema into a Tauri-free crate (#553)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:25:27 -07:00
Gregory Schier 93001e3da7 Extract generic model writes into yaak::models_ops (#552) 2026-08-15 11:01:41 -07:00
b31c066717 Load GraphQL schema from file for autocomplete (#462)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-15 10:14:13 -07:00
Gregory Schier 5d1d24870a Import from a URL in the Import Data dialog (#551) 2026-08-15 10:03:43 -07:00
Gregory Schier 6f0d0ef275 Don't save response when the file dialog is cancelled 2026-08-15 09:06:07 -07:00
Gregory Schier 85a9b2a908 Address response bodies by response id instead of a filesystem path (#550) 2026-08-15 08:21:23 -07:00
Gregory Schier f3f05502d1 Make the HTTP send path runnable without a database (#545) 2026-08-15 07:17:17 -07:00
pixel-hawk 2e0f7d1818 Add response filter history with pinning (#338) 2026-08-14 22:44:11 -07:00
Gregory Schier dc793181bb Add submenuTrigger option for dropdown items (#548) 2026-08-14 22:40:07 -07:00
Gregory Schier 7dfa7e07e3 Own response filter state where the filter runs (#549) 2026-08-14 22:38:38 -07:00
Gregory SchierandClaude Fable 5 23e7229e63 Route all app commands through a single RPC envelope (#542)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:16:14 -07:00
Gregory Schier 1f91cddab9 Restore HTTP/2 and client certificate parity on the native TLS path (#543) 2026-08-14 14:08:14 -07:00
Michaël ArnautsandGregory Schier be004425fa fix: Add username to the tokenStoreKey so the saved token is invalidated when the user changes in the environment (#426)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:50:48 -07:00
Gregory Schier 4f03c5c390 Fix sync watcher panic when the sync directory path traverses a symlink (#541) 2026-08-14 13:34:39 -07:00
Gregory Schier cb43f0b9b9 Follow the OS appearance when set to System without a new window (#540) 2026-08-14 13:14:01 -07:00
Mark FultonandGregory Schier 68671377fd fix: AWS4 authentication method didn't sign request body (#430)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:07:05 -07:00
Gregory SchierandClaude Opus 5 2383f06e71 Add a typed platform package to decouple the frontend from Tauri (#539)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:01 -07:00
Gregory Schier 0068be9ffc Run the CLI update check in non-interactive shells so agents see it 2026-08-14 12:27:38 -07:00
Gregory Schier def7d752db Add the MIT license field to the CLI npm packages 2026-08-14 12:27:38 -07:00
Gregory Schier 85aed740a8 Import Curl from a paste in the browser build (#538) 2026-08-14 11:54:56 -07:00
Gregory Schier d3055c64b3 Honor workspace connection settings in CLI sends (#537) 2026-08-14 10:38:47 -07:00
Gregory Schier 912aaeb7a7 Raise release build timeout to 60 minutes 2026-08-14 10:33:39 -07:00
Gregory Schier d0af512a0c Fix UI freezes with large workspaces (#536) 2026-08-14 10:15:32 -07:00
Gregory Schier e032c4c8d6 Add a Yaak CLI skill for coding agents (#535) 2026-08-14 06:16:30 -07:00
Carlos SimonandClaude Sonnet 4.6 c3fc3fb68a Fix Wayland crash on Nvidia by disabling explicit sync (#474)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 18:41:50 -07:00
Gregory Schier 7e2db7799b Collapse encoded media in editable fields too (#534) 2026-08-13 12:56:32 -07:00
Gregory Schier c596e25e5e Identify collapsed values and open them in a viewer (#533) 2026-08-13 12:26:35 -07:00
Gregory Schier 74d1b5d6ce HUGE sidebar and typing performance improvements (#516) 2026-08-13 12:23:56 -07:00
WkdXeqtr 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
SuandSu 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] 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] 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] 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] 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 Schier 0e4c355e8b Collapse very long lines in response viewers (#532) 2026-08-13 08:40:04 -07:00
Gregory Schier f6d926f4b9 Ignore bundled plugin directories left behind by past renames (#530) 2026-08-12 11:22:08 -07:00
Gregory Schier a6be9dbaee Allow renaming URL path parameters from the Params tab (#528) 2026-08-12 10:17:02 -07:00
Gregory Schier 67a628d67a Link date-fns format docs from timestamp.format (#529) 2026-08-12 09:50:36 -07:00
Gregory Schier 0bf7eaed81 Correct timestamp.format help text to reference date-fns, not dayjs (#527) 2026-08-12 08:12:19 -07:00
Kyungmin KimandCursor 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] 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] 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] 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] 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] 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] 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] 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
Su 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] 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 Schier d72b7d7d30 Fix URL autocomplete replacement (#510) 2026-07-15 08:33:14 -07:00
Gregory Schier b40e2cdc1b Fix keyboard shortcut help layout (#509) 2026-07-14 11:19:56 -07:00
Gregory Schier 19cc6ee6d4 Batch streamed request body history into full-size chunks (#508) 2026-07-14 09:13:50 -07:00
Gregory Schier e59ecce886 Fix Insomnia text body imports (#507) 2026-07-14 09:13:47 -07:00
Gregory Schier c30f5b767b Close responses before request body history finishes (#498) 2026-07-14 08:55:40 -07:00
Gregory Schier 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 Schier 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 Schier e20a184c70 Scope Git watching and status to the sync directory (#503) 2026-07-09 15:26:37 -07:00
Gregory Schier 155260521e Detect and surface plugin runtime crashes (#502) 2026-07-09 09:05:34 -07:00
Gregory Schier 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
Gregory SchierandClaude Fable 5 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 Schier 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 Schier 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 Schier 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 Schier 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 Schier 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
897 changed files with 92589 additions and 17404 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
{
"name": "yaak",
"interface": {
"displayName": "Yaak"
},
"plugins": [
{
"name": "yaak",
"source": {
"source": "local",
"path": "./crates-cli/yaak-cli"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "yaak",
"description": "Plugins for Yaak, the desktop API client",
"owner": {
"name": "Yaak",
"url": "https://yaak.app"
},
"plugins": [
{
"name": "yaak",
"source": "./crates-cli/yaak-cli",
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
"category": "development",
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
}
]
}
@@ -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".
+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
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
+55
View File
@@ -0,0 +1,55 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Build tooling majors are opted into, not queued. These never reach a
# user, so the cost of being a release behind is low and the cost of a
# surprise is a broken build. Note this also suppresses security PRs for
# these, so Dependabot alerts are the backstop.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
- dependency-name: vitest
update-types: ["version-update:semver-major"]
- dependency-name: "@types/*"
update-types: ["version-update:semver-major"]
- dependency-name: "vite-plugin-*"
update-types: ["version-update:semver-major"]
# 0.8 dropped stateExtensions and updateSchema from the package root and
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
# that editor moves to the jsonSchema helper.
- dependency-name: codemirror-json-schema
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
- dependency-name: oxc_resolver
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
+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,
}),
);
}
@@ -0,0 +1,79 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
+31 -1
View File
@@ -30,4 +30,34 @@ 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
- name: OpenAPI import round-trip
run: |
cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs
test-windows:
name: Rust Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: x64
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
-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
+2 -1
View File
@@ -13,6 +13,7 @@ permissions:
jobs:
update-flathub:
if: github.repository == 'mountain-loop/yaak'
name: Update Flathub manifest
runs-on: ubuntu-latest
steps:
@@ -34,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: |
+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:
+122 -15
View File
@@ -5,6 +5,7 @@ on:
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions:
contents: write
@@ -14,38 +15,62 @@ 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
timeout-minutes: 360
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
@@ -66,11 +91,30 @@ 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
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
# through to host glibc headers ("bits/libc-header-start.h" not found).
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
# this compiler, so the shipped binary keeps 22.04's glibc floor.
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
chmod +x /tmp/llvm.sh
sudo /tmp/llvm.sh 18
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
@@ -98,7 +142,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
@@ -128,7 +172,8 @@ jobs:
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -150,13 +195,49 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
# Also retries builds; upload retries alone cannot clean up starter assets.
retryAttempts: 3
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[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 }}"
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
- name: Snapshot Tauri bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
version="${GITHUB_REF_NAME#v}"
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -173,7 +254,8 @@ 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"}}}}'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
@@ -181,5 +263,30 @@ jobs:
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
foreach ($asset in @($dest, $destSig)) {
for ($attempt = 1; $attempt -le 4; $attempt++) {
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 4) { exit $LASTEXITCODE }
Start-Sleep -Seconds 10
}
}
- name: Snapshot additional release bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Retain release bundles for recovery
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
+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
+123
View File
@@ -0,0 +1,123 @@
name: Release web image
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
# One image per architecture on its own native runner (emulating a Rust release build is hours),
# joined into one multi-arch tag at the end.
on:
push:
tags: [v*]
workflow_dispatch:
inputs:
version:
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
required: false
type: string
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/mountain-loop/yaak-web
jobs:
build:
if: github.repository == 'mountain-loop/yaak'
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-22.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish manifest
needs: build
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `latest` follows a release tag, and a manual run that names a version — the way to
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
- name: Tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
type=ref,event=branch
type=sha,format=short
- name: Create and push the manifest
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
+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/
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
+2 -1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
]
}
+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
+2661 -1207
View File
File diff suppressed because it is too large. Load diff
+35 -4
View File
@@ -2,16 +2,21 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
"crates/common/yaak-rpc-schema",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
"crates/yaak-filter",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-mcp",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -19,10 +24,14 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-wasm",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
"crates-server/yaak-playground",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -31,6 +40,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",
]
@@ -40,16 +50,27 @@ chrono = "0.4.42"
hex = "0.4.3"
keyring = "3.6.3"
log = "0.4.29"
reqwest = "0.12.20"
reqwest = { version = "0.13.5", default-features = false, features = [
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
# rest of the old default set and stay on native-tls.
"charset",
"http2",
"system-proxy",
"native-tls",
] }
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
rustls-platform-verifier = "0.7.1"
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"
@@ -58,15 +79,20 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-filter = { path = "crates/yaak-filter" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
@@ -84,8 +110,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" }
+19
View File
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
npm start
```
## Run the App in a Browser
The client can also run as a plain web page, with no Tauri and no local process
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
That flag picks the browser host in `packages/platform/src/web/`, which answers
commands from an IndexedDB database the page owns instead of from the Rust
engine. Data persists across reloads and is shared between tabs on the same
origin. Sending HTTP is not available yet — the Send button reports that and
everything else about the request is still saved. `packages/platform/src/web/README.md`
lists which commands the browser host implements and which it declines.
Desktop builds are unaffected: without the flag the platform package installs
the Tauri host exactly as before.
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
+15
View File
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
FROM rust:1-bookworm AS build
WORKDIR /app
COPY . .
RUN cargo build --release -p yaak-playground
FROM debian:bookworm-slim
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
CMD ["yaak-playground"]
+49
View File
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
# image serving both from one origin.
#
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
#
# See crates-server/yaak-web/README.md for the knobs.
FROM node:22-slim AS web
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
COPY . .
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
RUN git init -q && git add -A \
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
# only to build a bundle for a deployment whose server lives somewhere else.
ARG VITE_YAAK_WEB_URL=""
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
ENV YAAK_TARGET=web
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
# backend, which this image has no reason to carry.
ENV SKIP_WASM_BUILD=1
RUN npm ci
RUN node_modules/.bin/vp -C apps/yaak-client build
FROM rust:1-bookworm AS server
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release -p yaak-web
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor:
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
CMD ["yaak-web", "--serve", "/srv"]
+12 -2
View File
@@ -17,14 +17,18 @@
<br>
<p align="center">
<!-- 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 -->
<!-- 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/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></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)
## Install
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
@@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
## License
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
+148
View File
@@ -0,0 +1,148 @@
import type { FunctionComponent, ReactElement } from "react";
import { beforeEach, expect, test, vi } from "vite-plus/test";
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
import type { DialogInstance } from "../components/Dialogs";
import { syncWorkspace } from "./commands";
const mocks = vi.hoisted(() => ({
calculateSync: vi.fn(),
applySync: vi.fn(),
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
hideDialog: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@yaakapp-internal/sync", () => mocks);
vi.mock("@yaakapp-internal/models", () => ({
createWorkspaceModel: vi.fn(),
modelTypeLabel: () => "Request",
}));
vi.mock("@yaakapp-internal/ui", () => ({
Banner: "div",
InlineCode: "code",
Table: "table",
TableBody: "tbody",
TableCell: "td",
TableHead: "thead",
TableHeaderCell: "th",
TableRow: "tr",
TruncatedWideTableCell: "td",
}));
vi.mock("../components/core/Button", () => ({ Button: "button" }));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
vi.mock("../lib/dialog", () => mocks);
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
vi.mock("../lib/toast", () => mocks);
const staleToast = expect.objectContaining({
message: "Files changed while syncing. Review the changes again.",
});
const params = { workspaceId: "wk_test", syncDir: "/sync" };
const deletion = {
type: "dbDelete",
model: { id: "rq_test", model: "http_request" },
state: {},
} as Extract<SyncOp, { type: "dbDelete" }>;
const update = {
type: "dbUpdate",
model: { id: "rq_test", model: "http_request" },
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
state: {},
} as Extract<SyncOp, { type: "dbUpdate" }>;
async function submit(dialog: DialogInstance) {
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
}>;
await form.props.onSubmit({ preventDefault: vi.fn() });
}
function latestDialog() {
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
if (dialog == null) throw new Error("Expected a sync dialog");
return dialog;
}
beforeEach(() => {
vi.resetAllMocks();
mocks.applySync.mockResolvedValue(true);
});
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
await syncWorkspace.mutateAsync(params);
mocks.applySync.mockResolvedValueOnce(false);
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("requires another confirmation for a changed plan", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
await syncWorkspace.mutateAsync(params);
const original = latestDialog();
mocks.applySync.mockResolvedValueOnce(false);
await submit(original);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.hideDialog).not.toHaveBeenCalled();
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("applies an unchanged reviewed plan and closes the dialog", async () => {
mocks.calculateSync.mockResolvedValue([update]);
await syncWorkspace.mutateAsync(params);
await submit(latestDialog());
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("forced sync retries the current plan without requesting another confirmation", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
mocks.applySync.mockResolvedValueOnce(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.applySync).toHaveBeenCalledTimes(2);
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
test("outbound-only changes clear the old dialog and sync automatically", async () => {
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
await syncWorkspace.mutateAsync(params);
await syncWorkspace.mutateAsync(params);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("stops retrying a plan that keeps going stale", async () => {
mocks.calculateSync.mockResolvedValue([update]);
mocks.applySync.mockResolvedValue(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
expect(mocks.applySync).toHaveBeenCalledTimes(3);
expect(mocks.showToast).toHaveBeenCalledTimes(1);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
+34 -11
View File
@@ -14,11 +14,12 @@ import {
} from "@yaakapp-internal/ui";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { hideDialog, showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { showPrompt } from "../lib/prompt";
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const createFolder = createFastMutation<
string | null,
@@ -52,24 +53,48 @@ export const createFolder = createFastMutation<
},
});
// Files can keep changing under a plan, so stop recalculating after a few stale applies.
const MAX_SYNC_ATTEMPTS = 3;
function showStaleSyncToast() {
showToast({
id: "sync-stale",
color: "warning",
message: "Files changed while syncing. Review the changes again.",
});
}
export const syncWorkspace = createFastMutation<
void,
void,
{ workspaceId: string; syncDir: string; force?: boolean }
{ workspaceId: string; syncDir: string; force?: boolean; attempt?: number }
>({
mutationKey: [],
mutationFn: async ({ workspaceId, syncDir, force }) => {
mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) {
console.log("Nothing to sync", workspaceId, syncDir);
hideDialog("commit-sync");
return;
}
console.log("Syncing workspace", workspaceId, syncDir, ops);
const dbOps = ops.filter((o) => o.type.startsWith("db"));
if (dbOps.length === 0) {
await applySync(workspaceId, syncDir, ops);
const apply = async () => {
if (await applySync(workspaceId, syncDir, ops)) {
hideDialog("commit-sync");
} else if (attempt < MAX_SYNC_ATTEMPTS) {
await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 });
} else {
hideDialog("commit-sync");
showStaleSyncToast();
}
};
if (dbOps.length === 0 || force) {
hideDialog("commit-sync");
await apply();
return;
}
@@ -79,9 +104,8 @@ export const syncWorkspace = createFastMutation<
console.log("Directory changes detected", { dbOps, ops });
if (force) {
await applySync(workspaceId, syncDir, ops);
return;
if (attempt > 1) {
showStaleSyncToast();
}
showDialog({
@@ -93,8 +117,7 @@ export const syncWorkspace = createFastMutation<
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
onSubmit={async (e) => {
e.preventDefault();
await applySync(workspaceId, syncDir, ops);
hide();
await apply();
}}
>
{isDeletingWorkspace ? (
@@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
/**
* Prompt for and create a sub-environment of the given base environment, resolving with the new
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
* active environment.
*/
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
@@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation<
});
});
},
});
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment_and_activate"],
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
disableToastError: true, // Already shown by createSubEnvironment
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
+52
View File
@@ -0,0 +1,52 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModelById } from "@yaakapp-internal/models";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { jotaiStore } from "../lib/jotai";
import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast";
export function looksLikeCurl(text: string) {
return text.trim().startsWith("curl ");
}
export const importCurl = createFastMutation<
void,
string,
{ overwriteRequestId?: string; command: string }
>({
mutationKey: ["import_curl"],
mutationFn: async ({ overwriteRequestId, command }) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
command,
workspaceId,
});
let verb: string;
if (overwriteRequestId == null) {
verb = "Created";
await createRequestAndNavigate(importedRequest);
} else {
verb = "Updated";
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
...importedRequest,
id: r.id,
createdAt: r.createdAt,
workspaceId: r.workspaceId,
folderId: r.folderId,
name: r.name,
sortPriority: r.sortPriority,
}));
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
}
showToast({
color: "success",
message: `${verb} request from Curl`,
});
},
});
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const moveToWorkspace = createFastMutation({
mutationKey: ["move_workspace"],
+25 -7
View File
@@ -1,26 +1,44 @@
import type { SettingsTab } from "../components/Settings/Settings";
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
// Settings is its own window where the host has windows to give. Where it
// doesn't — a browser tab — it's a dialog like any other, so opening it
// doesn't take you away from the request you were working on.
if (!platform.capabilities.multiWindow) {
// Imported here so Settings stays out of the startup bundle, the way the
// route that renders it on desktop already keeps it
const { default: Settings } = await import("../components/Settings/Settings");
showDialog({
id: "settings",
size: "md",
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
noPadding: true,
noScroll: true,
// Keyed so opening a specific tab while the dialog is already up moves to it
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
});
return;
}
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await invokeCmd("cmd_new_child_window", {
await rpc("cmd_new_child_window", {
url: location.href,
label: "settings",
title: "Yaak Settings",
@@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
return;
}
await applySync(workspace.id, dir, ops);
await applySync(workspace.id, dir, ops, { fsOnly: true });
await router.navigate({
to: "/workspaces/$workspaceId",
@@ -1,9 +1,10 @@
import { platform } from "@yaakapp-internal/platform";
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export const switchWorkspace = createFastMutation<
void,
@@ -24,13 +25,15 @@ export const switchWorkspace = createFastMutation<
request_id: requestId,
};
if (inNewWindow) {
// A host without windows opens the workspace here instead. Refusing would
// strand the user on the workspace they were trying to leave.
if (inNewWindow && platform.capabilities.multiWindow) {
const location = router.buildLocation({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search,
});
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
await rpc<void>("cmd_new_main_window", { url: location.href });
return;
}
@@ -1,16 +1,16 @@
import { open } from "@tauri-apps/plugin-dialog";
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
import { platform } from "@yaakapp-internal/platform";
interface Props {
hide: () => void;
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -38,7 +39,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
const dir = await platform.dialog.open({
title: "Select Directory",
directory: true,
multiple: false,
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
</VStack>
);
}
@@ -1,11 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { checkLicense } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { platform } from "@yaakapp-internal/platform";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
@@ -95,7 +94,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
const license = await checkLicense();
return license.status === "personal_use";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
@@ -104,7 +103,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useToggle } from "../hooks/useToggle";
import { showConfirm } from "../lib/confirm";
import { Button } from "./core/Button";
import { Link } from "./core/Link";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -1,5 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
@@ -8,7 +8,6 @@ import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -1,5 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
@@ -7,7 +7,6 @@ import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -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>
))}
</>
);
}
+2 -1
View File
@@ -25,12 +25,13 @@ import {
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
+16 -25
View File
@@ -1,31 +1,22 @@
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
import {
CopyIconButton as BaseCopyIconButton,
type CopyIconButtonProps,
} from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
text: string | (() => Promise<string | null>);
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
}
export function CopyIconButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? "check" : "copy"}
showConfirm
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
function copy(text: string) {
copyToClipboard(text, { disableToast: true });
}
function showCopyFailed() {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
}
@@ -1,12 +1,12 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react";
import { useId, useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { Label } from "./core/Label";
import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput";
import { Label } from "@yaakapp-internal/ui";
interface Props {
onCreate: (id: string) => void;
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
className="pb-3 flex flex-col gap-3"
id={formId}
className="flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
</form>
);
}
@@ -1,15 +1,14 @@
import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { VStack, Label } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { Label } from "./core/Label";
import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from "./git/callbacks";
@@ -26,12 +25,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -39,7 +39,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
// Do getWorkspaceMeta instead of naively creating one because it might have
// been created already when the store refreshes the workspace meta after
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
workspaceId,
});
await updateModel({
@@ -89,9 +89,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
</VStack>
);
}
+1 -2
View File
@@ -11,7 +11,7 @@ import type {
FormInputText,
JsonPrimitive,
} from "@yaakapp-internal/plugins";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
)}
// If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
onClick={
subEnvironments.length === 0
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
@@ -1,11 +1,19 @@
import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createSubEnvironment } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
environmentsBreakdownAtom,
@@ -22,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import type { PairEditorHandle } from "./core/PairEditor";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentEditor } from "./EnvironmentEditor";
@@ -40,6 +47,9 @@ interface Props {
type TreeModel = Environment | Workspace;
/** Lets tree row slots create a sub-environment and select it in the editor. */
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -115,7 +125,7 @@ function EnvironmentEditDialogSidebar({
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => {
@@ -124,6 +134,31 @@ function EnvironmentEditDialogSidebar({
treeRef.current?.focus();
}, []);
// A just-created environment to select once it reaches the model store. Selecting it before
// then would show a "failed to find" error in the editor, and the tree would drop the
// selection of an item it doesn't have yet.
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
const tree = useAtomValue(treeAtom);
useEffect(() => {
if (pendingSelectionId == null) return;
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
setPendingSelectionId(null);
setSelectedEnvironmentId(pendingSelectionId);
// The tree leaves out sub-environments while there are multiple base environments
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
if (node == null) return;
if (node.parent != null) {
// Expand the parent so the new item is visible to be selected
const parentId = node.parent.item.id;
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
}
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
// before the new item arrived, don't reset this selection
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
@@ -134,6 +169,14 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const handleCreateSubEnvironment = useCallback(async () => {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
// Show the new environment in the editor without changing the workspace's active environment
if (id != null) setPendingSelectionId(id);
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -195,13 +238,14 @@ function EnvironmentEditDialogSidebar({
const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
onSelect: handleCreateSubEnvironment,
};
// Sub-environments aren't shown while there are multiple base environments, same as the
// plus button on the base environment row
const canCreateSubEnvironment = baseEnvironments.length <= 1;
if (environment == null || environment.model !== "environment") {
return [addEnvironmentItem];
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
}
const singleEnvironment = items.length === 1;
@@ -257,7 +301,7 @@ function EnvironmentEditDialogSidebar({
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem);
}
@@ -266,6 +310,7 @@ function EnvironmentEditDialogSidebar({
},
[
baseEnvironments.length,
handleCreateSubEnvironment,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
@@ -317,34 +362,37 @@ function EnvironmentEditDialogSidebar({
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
({ items, position, onClose }) =>
// Right-clicking empty space has nothing to offer while there are multiple base environments
items.length === 0 ? null : (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
</CreateSubEnvironmentContext.Provider>
)}
</aside>
);
@@ -384,6 +432,15 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
return root;
});
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
if (node.item.id === id) return node;
for (const child of node.children ?? []) {
const found = findTreeNode(child, id);
if (found != null) return found;
}
return null;
}
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
@@ -395,6 +452,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
return (
<>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
@@ -405,7 +463,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={createSubEnvironment}
onClick={handleCreateSubEnvironment}
/>
)}
</>
@@ -425,13 +483,6 @@ function ItemInner({ item }: { item: TreeModel }) {
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name,
@@ -1,4 +1,4 @@
import { IconTooltip } from "./core/IconTooltip";
import { IconTooltip } from "@yaakapp-internal/ui";
export function EnvironmentSharableTooltip() {
return (
@@ -1,18 +1,18 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onHide: () => void;
@@ -65,29 +65,30 @@ function ExportDataDialogContent({
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const exportPath = await save({
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
if (exportPath == null) {
return;
}
await invokeCmd("cmd_export_data", {
const document = await rpc<string>("cmd_export_data", {
workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments,
});
const savedTo = await platform.files.save(
`yaak.${slug}.json`,
new TextEncoder().encode(document),
[{ name: "JSON", extensions: ["json"] }],
);
if (savedTo == null) {
return; // Cancelled
}
onHide();
onSuccess(exportPath);
onSuccess(savedTo);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<>
<VStack space={3} className="pb-4">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -140,29 +141,22 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<DialogFooter
leftSlot={
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
);
}
@@ -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 { rpc } from "../lib/rpc";
import { hideToastById, showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Input } from "./core/Input";
interface Props {
feature: FeedbackFeature;
onDone: () => void;
}
export function FeedbackToast({ feature, onDone }: Props) {
const [text, setText] = useState<string>("");
const [sent, setSent] = useState(false);
const sentRef = useRef(false);
const handleDismiss = () => {
onDone();
hideToastById(`feature-feedback-${feature}`);
};
const handleSend = () => {
const trimmedText = text.trim();
if (sentRef.current || trimmedText.length === 0) return;
sentRef.current = true;
setSent(true);
onDone();
// Fire-and-forget; failures are intentionally ignored
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
color: "success",
message: "Thanks for the feedback!",
});
};
return (
<VStack space={2}>
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
<div className="h-20">
<Input
size="xs"
// The editor forces its mono font on the scroller, so the override
// has to target it directly
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
label="Feedback"
hideLabel
stateKey={null}
multiLine
fullHeight
placeholder="Your thoughts..."
onChange={setText}
/>
</div>
<HStack space={1.5} justifyContent="end">
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
Dismiss
</Button>
<Button
size="xs"
color="primary"
disabled={sent || text.trim().length === 0}
onClick={handleSend}
>
Send
</Button>
</HStack>
</VStack>
);
}
+9 -4
View File
@@ -1,6 +1,14 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import {
Heading,
HStack,
Icon,
LoadingIcon,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { Button } from "./core/Button";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { Separator } from "./core/Separator";
import { SizeTag } from "./core/SizeTag";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props {
@@ -1,5 +1,5 @@
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { Fragment, useMemo } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { hideDialog } from "../lib/dialog";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { Input } from "./core/Input";
import { Link } from "./core/Link";
import type { TabItem } from "./core/Tabs/Tabs";
@@ -1,3 +1,4 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -5,10 +6,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { router } from "../lib/router";
export function GlobalHooks() {
useSyncZoomSetting();
@@ -24,6 +28,20 @@ export function GlobalHooks() {
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
usePlatformEvent("show_home", () => {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey(
"request.rename",
async () => {
+182 -64
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
@@ -11,15 +10,23 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import { pluralizeCount } from "../lib/pluralize";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null;
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
console.log("Failed to find service", { service: request.service, services });
showAlert({
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
updateSchema(editorView, JSON.parse(schema));
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
showAlert({
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
log: ["Failed to parse schema", err],
};
}
}, [editorView, services, request.method, request.service]);
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
@@ -124,45 +141,146 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "info"
: reflectionError
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
@@ -1,13 +1,13 @@
import { open } from "@tauri-apps/plugin-dialog";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onDone: () => void;
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
}
return (
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
<>
<VStack space={5} className="pb-4">
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
</VStack>
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
);
}
+17 -16
View File
@@ -1,5 +1,10 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
@@ -10,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown";
@@ -75,11 +79,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],
);
@@ -126,6 +132,8 @@ export function GrpcRequestPane({
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
@@ -139,19 +147,19 @@ export function GrpcRequestPane({
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />,
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
[authTab, hasDescription, metadataTab, numSettingsOverrides],
);
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],
);
@@ -187,13 +195,6 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
@@ -299,7 +300,7 @@ export function GrpcRequestPane({
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"
@@ -1,5 +1,13 @@
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import {
HStack,
Icon,
type IconProps,
LoadingIcon,
VStack,
useStateWithDeps,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -9,13 +17,12 @@ import {
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
+33 -21
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 { HStack, CountBadge } 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 { CountBadge } from "./core/CountBadge";
import { userAgents } from "../lib/data/userAgents";
import { validateHttpHeader } from "../lib/validateHttpHeader";
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,13 +178,3 @@ const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};
+64 -56
View File
@@ -1,25 +1,22 @@
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 { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useImportCurl } from "../hooks/useImportCurl";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { 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,
@@ -36,11 +33,10 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
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 { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -55,6 +51,7 @@ import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
const GraphQLEditor = lazy(() =>
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
@@ -75,15 +72,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 +125,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 (
@@ -156,6 +163,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
: 0;
}
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(
() => [
{
@@ -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;
@@ -244,6 +260,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[
@@ -251,6 +268,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
@@ -262,38 +280,28 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const updateKey = useRequestUpdateKey(activeRequestId);
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],
);
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
if (text.startsWith("curl ")) {
importCurl({ overwriteRequestId: activeRequestId, command: text });
if (looksLikeCurl(text)) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
const patch = prepareImportQuerystring(text);
if (patch != null) {
@@ -315,7 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
}
},
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
@@ -323,7 +331,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -369,7 +377,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 +385,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 +437,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" ? (
@@ -1,5 +1,21 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import {
Banner,
HStack,
Icon,
LoadingIcon,
VStack,
CountBadge,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
Tooltip,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
@@ -8,6 +24,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -16,15 +33,10 @@ import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
@@ -32,14 +44,9 @@ import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -409,14 +416,13 @@ function EnsureCompleteResponse({
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>;
Component: ComponentType<{ bodyUrl: string }>;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
@@ -424,7 +430,15 @@ function EnsureCompleteResponse({
);
}
return <Component bodyPath={response.bodyPath} />;
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
import { KeyValueRow } from "./core/KeyValueRow";
import type { TimelineViewMode } from "./HttpResponsePane";
interface Props {
+112
View File
@@ -0,0 +1,112 @@
import { platform, useCapability } from "@yaakapp-internal/platform";
import { Icon } from "@yaakapp-internal/ui";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
/**
* Offers to create a request from a Curl command on the clipboard. A host that can read
* the clipboard on its own offers it whenever the window is focused; one that would have
* to prompt for permission first waits for the user to paste instead.
*/
export function ImportCurl() {
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
}
function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void platform.clipboard.readText().then(setClipboardText);
}, [focused]);
if (!looksLikeCurl(clipboardText)) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
setClipboardText(""); // Hide the button until the clipboard changes
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
function ImportCurlOnPaste() {
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const command = e.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCurl(command)) return;
// Editable targets keep the text as text. The URL editor imports it itself.
if (isEditable(e.target)) return;
showToast({
id: "curl-paste",
color: "success",
message: (
<div>
<h2 className="font-semibold">Curl command detected</h2>
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
</div>
),
action: ({ hide }) => (
<Button
size="xs"
color="success"
className="mr-auto min-w-20"
rightSlot={<Icon icon="import" size="sm" />}
onClick={() => {
hide();
importCurl.mutate({ command });
}}
>
Create Request
</Button>
),
});
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, []);
return null;
}
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
if (target.closest(".cm-editor") != null) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
@@ -1,56 +0,0 @@
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { useImportCurl } from "../hooks/useImportCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { Button } from "./core/Button";
import { Icon } from "@yaakapp-internal/ui";
export function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const importCurl = useImportCurl();
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void readText().then(setClipboardText);
}, [focused]);
if (!clipboardText?.trim().startsWith("curl ")) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
await clear(); // Clear the clipboard so the button goes away
setClipboardText("");
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
+748 -43
View File
@@ -1,57 +1,762 @@
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import {
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props {
importData: (filePath: string) => Promise<void>;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
export function ImportDataDialog({ importData }: Props) {
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
planSources,
detectSource,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
);
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
);
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
};
useEffect(() => {
let cancelled = false;
if (sources.length === 0) {
setOriginSources([]);
return;
}
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => {
cancelled = true;
};
}, [sources, findSourcesForOrigin]);
// The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
};
};
const handlePreview = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan = await planSources(sources, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
setSourceError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
}
const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return ["Destination", name];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})();
return (
<>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.level}:${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
</div>
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
);
}
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<Select
name="import-destination-kind"
label="Import into"
labelPosition="left"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
</HStack>
);
return (
<VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}>
<ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li>
<li>Postman Collection v2, v2.1</li>
<li>Insomnia v4+</li>
<li>Swagger 2.0</li>
<li>
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
</li>
</ul>
</VStack>
<VStack space={2}>
<SelectFile
filePath={filePath ?? null}
onChange={({ filePath }) => setFilePath(filePath)}
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{filePath && (
<Button
color="primary"
disabled={!filePath || isLoading}
isLoading={isLoading}
size="sm"
onClick={async () => {
setIsLoading(true);
try {
await importData(filePath);
} finally {
setIsLoading(false);
}
}}
>
{isLoading ? "Importing" : "Import"}
</Button>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)}
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle">
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</InlineButton>
</div>
)}
</VStack>
</VStack>
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
);
}
function ImportTreeRow({
row,
onResolveConflict,
}: {
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" color="success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
</div>
) : (
label != null && (
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
)
)}
</>
);
}
function ActionChip({
label,
help,
color,
}: {
label: string;
help: string | null;
color: ComponentProps<typeof Chip>["color"];
}) {
return (
<Chip color={color}>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</Chip>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Not in this workspace yet";
case "update":
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
@@ -1,7 +1,7 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
@@ -12,7 +12,6 @@ import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
@@ -0,0 +1,279 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import {
Banner,
Button,
HStack,
LoadingIcon,
AudioViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
/** The whole value, exactly as it reads in the document */
text: string;
sniffed: SniffedValue | null;
}
/**
* Shows a value the editor collapsed, in whatever viewer its type calls for.
*
* The editor only ever read the value's head, so this is where it is decoded in full — on an
* explicit click, behind a spinner, rather than during layout.
*/
export function LargeValueDialog({ text, sniffed }: Props) {
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
const decoded = useDecoded(text, viewer == null ? null : sniffed);
// Nothing recognised it, so there is nothing to decode it into
if (sniffed == null || viewer == null) {
return (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
*/
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
*
* It lives in the dialog's title rather than above the content, which would cost a band of
* whitespace the width of the dialog to hold one small button.
*/
function LargeValueActions({ text, sniffed }: Props) {
const items = useMemo(
() =>
largeValueActions({
value: () => text,
sniffed,
// The tag copies only what it hides; in here, what you see is the whole value
copyText: () => text,
}),
[text, sniffed],
);
return (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
/** Which viewer a media type calls for, or null if none of them can show it. */
function viewerFor(mime: string): Viewer | null {
if (mime.startsWith("image/svg")) return "svg";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
return null;
}
type Decoded = { bytes: Uint8Array } | { error: string };
/**
* The value's bytes, once they exist.
*
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
* during render — the dialog paints with a spinner first, instead of opening late.
*/
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
const [decoded, setDecoded] = useState<Decoded | null>(null);
useEffect(() => {
if (sniffed == null) return;
setDecoded(null);
let cancelled = false;
const timer = setTimeout(() => {
let result: Decoded;
try {
result = { bytes: decodeValue(text, sniffed) };
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
if (!cancelled) setDecoded(result);
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [text, sniffed]);
return decoded;
}
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
const PAGE_CHARS = 50_000;
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
const WRAP_CHARS = 120;
/**
* The raw text, in pages of short lines.
*
* What is left when nothing can render the value. Handing it to an editor whole would walk
* straight back into the layout stall that collapsed it in the first place, so it is paged and
* hard-wrapped instead: no line is ever long, and no page is ever big.
*/
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
const from = page * PAGE_CHARS;
const to = Math.min(from + PAGE_CHARS, text.length);
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
function wrap(text: string): string {
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= WRAP_CHARS) {
lines.push(line);
continue;
}
for (let i = 0; i < line.length; i += WRAP_CHARS) {
lines.push(line.slice(i, i + WRAP_CHARS));
}
}
return lines.join("\n");
}
export function showLargeValueDialog({ text, sniffed }: Props) {
showDialog({
id: "large-value",
size: "lg",
className: "h-[calc(100vh-10rem)]",
// Everything in one line: the same words the tag uses, and the same menu it carries. A
// separate description and a row of its own for the button cost three bands to say this.
title: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}
+3 -3
View File
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useLicense } from "@yaakapp-internal/license";
import { settingsAtom } from "@yaakapp-internal/models";
@@ -14,6 +13,7 @@ import type { ButtonProps } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { PillButton } from "./core/PillButton";
import { platform } from "@yaakapp-internal/platform";
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
@@ -52,7 +52,7 @@ function getDetail(
leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => openUrl(data.data.changesUrl ?? ""),
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
},
{
type: "separator",
@@ -63,7 +63,7 @@ function getDetail(
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => openUrl(data.data.billingUrl),
onSelect: () => platform.openUrl(data.data.billingUrl),
},
{
label: "Enter License Key",
+3 -4
View File
@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { convertFileSrc } from "@tauri-apps/api/core";
import { resolveResource } from "@tauri-apps/api/path";
import classNames from "classnames";
import { platform } from "@yaakapp-internal/platform";
interface Props {
src: string;
@@ -12,8 +11,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ["local-image", srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
return convertFileSrc(p);
const p = await platform.files.resolveResource(srcPath);
return platform.files.url(p);
},
});
@@ -2,7 +2,9 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
@@ -13,6 +15,7 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
@@ -21,6 +24,7 @@ import {
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
@@ -38,37 +42,21 @@ interface Props {
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
}
/>
)}
{supportsHttpSettings && (
<HttpVersionSettingRow
settingDefinition={SETTING_HTTP_VERSION}
setting={model.settingHttpVersion}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_HTTP_VERSION.modelKey,
model.settingHttpVersion,
)}
onChange={(settingHttpVersion) =>
patchHttpSettings(model, {
settingHttpVersion,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -232,10 +231,7 @@ function patchCookieSettings(
}
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -246,10 +242,7 @@ function patchHttpSettings(
}
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
@@ -317,11 +304,7 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
@@ -352,6 +335,63 @@ function BooleanSettingRow({
);
}
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1", value: "http1" },
{ label: "HTTP/2", value: "http2" },
];
function HttpVersionSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: HttpVersion;
setting: HttpVersionSetting;
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
onChange: (setting: HttpVersionSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange(value)}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
@@ -365,18 +405,11 @@ function IntegerSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -567,13 +593,18 @@ function resolveInheritedValue(
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingHttpVersion",
fallback: HttpVersionSetting,
): HttpVersion;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
@@ -589,6 +620,7 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
}
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Select } from "./core/Select";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
activeWorkspaceId: string;
+212
View File
@@ -0,0 +1,212 @@
import type { Color } from "@yaakapp-internal/plugins";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import {
createExampleWorkspace,
createFreshWorkspace,
type OnboardingChoice,
recordOnboardingChoice,
} from "../lib/onboarding";
import { showErrorToast } from "../lib/toast";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
recordOnboardingChoice(choice);
setBusy(choice);
try {
await run();
} catch (err) {
showErrorToast({
id: "onboarding-failed",
title: "Something went wrong",
message: String(err),
});
} finally {
setBusy(null);
}
};
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
/>
<div className="overflow-auto px-6 py-10 grid">
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
title="Try Yaak with a real API"
description="Ready-made requests you can send right away"
busy={busy === "example"}
disabled={busy != null}
onClick={choose("example", createExampleWorkspace)}
/>
<StartOption
color="success"
icon="plus"
title="Start fresh"
description="An empty workspace for your first request"
busy={busy === "fresh"}
disabled={busy != null}
onClick={choose("fresh", createFreshWorkspace)}
/>
</div>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function StartOption({
color,
icon,
title,
description,
onClick,
busy,
disabled,
}: {
color: Color;
icon: IconProps["icon"];
title: string;
description: string;
onClick: () => void;
busy: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={classNames(
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
"outline-border-focus focus-visible:outline-2",
disabled && !busy && "opacity-disabled",
)}
>
{/* The banner theme tints this tile from the theme's own color for each option */}
<div
className={classNames(
`x-theme-banner--${color}`,
"size-10 rounded-md bg-surface-highlight grid place-items-center",
)}
>
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
</div>
<div className="min-w-0">
<div className="font-semibold text-text">{title}</div>
<div className="text-sm text-text-subtle">{description}</div>
</div>
<Icon
icon="chevron_right"
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
/>
</button>
);
}
@@ -1,6 +1,6 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: GrpcConnection[];
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props {
responses: HttpResponse[];
@@ -92,7 +89,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
onSelect: () => {
onPinnedResponseId(r.id);
},
});
}
@@ -1,6 +1,6 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: WebsocketConnection[];
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return;
}
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null;
}
@@ -1,17 +1,20 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from "@yaakapp-internal/ui";
import { getMimeTypeFromContentType } from "../lib/contentType";
import {
LoadingIcon,
languageFromContentType,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from "./responseViewers/WebPageViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -3,9 +3,9 @@ import classNames from "classnames";
import { useMemo } from "react";
import type { JSX } from "react/jsx-runtime";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui";
interface Props {
response: HttpResponse;
+17 -64
View File
@@ -1,31 +1,16 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<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>}>
@@ -45,7 +30,7 @@ export function ResponseHeaders({ response }: Props) {
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
@@ -58,53 +43,21 @@ export function ResponseHeaders({ response }: Props) {
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
<HeadersTable
title="Request Headers"
headers={response.requestHeaders}
labelColor="primary"
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
useStoredValue={useDetailsBannerStoredValue}
/>
<HeadersTable
defaultOpen
title="Response Headers"
headers={response.headers}
labelColor="info"
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
useStoredValue={useDetailsBannerStoredValue}
/>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}
+4 -3
View File
@@ -1,7 +1,8 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
import { KeyValueRows } from "@yaakapp-internal/ui";
interface Props {
response: HttpResponse;
@@ -26,7 +27,7 @@ export function ResponseInfo({ response }: Props) {
iconSize="sm"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
+18 -32
View File
@@ -1,6 +1,5 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog";
import { HStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
import type { ReactNode } from "react";
@@ -8,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
type Props = Omit<ButtonProps, "type"> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
@@ -41,7 +38,7 @@ export function SelectFile({
...props
}: Props) {
const handleClick = async () => {
const filePath = await open({
const filePath = await platform.dialog.open({
title: directory ? "Select Folder" : "Select File",
multiple: false,
directory,
@@ -64,32 +61,21 @@ export function SelectFile({
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
// as browser drag-n-drop.
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
const webview = getCurrentWebviewWindow();
unlisten = await webview.onDragDropEvent((event) => {
if (event.payload.type === "over") {
const p = event.payload.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
console.log("IS OVER", isOver);
setIsHovering(isOver);
} else if (event.payload.type === "drop" && isHovering) {
console.log("User dropped", event.payload.paths);
const p = event.payload.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
console.log("File drop cancelled");
setIsHovering(false);
}
});
};
setup().catch(console.error);
return () => {
if (unlisten) unlisten();
};
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
setIsHovering(isOver);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, onChange]);
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
@@ -1,16 +1,14 @@
import { useSearch } from "@tanstack/react-router";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { CountBadge } from "../core/CountBadge";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsHotkeys } from "./SettingsHotkeys";
@@ -21,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
@@ -31,6 +31,7 @@ const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents";
const TAB_LICENSE = "license";
const tabs = [
TAB_GENERAL,
@@ -38,30 +39,25 @@ const tabs = [
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_AGENTS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
export default function Settings({ tab, hide }: Props) {
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
const [mainTab, subtab] = tab?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide != null) {
// It's being shown in a dialog, so close the dialog
hide();
} else {
// It's being shown in a window, so close the window
await getCurrentWebviewWindow().close();
}
if (hide == null) await platform.window.close();
});
return (
@@ -75,7 +71,7 @@ export default function Settings({ hide }: Props) {
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={type()}
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
@@ -85,13 +81,13 @@ export default function Settings({ hide }: Props) {
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab || tabFromQuery}
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
@@ -99,7 +95,9 @@ export default function Settings({ hide }: Props) {
(value): TabItem => ({
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
@@ -115,6 +113,8 @@ export default function Settings({ hide }: Props) {
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
@@ -123,6 +123,8 @@ export default function Settings({ hide }: Props) {
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_AGENTS && settings.mcpServerEnabled ? (
<CountBadge count />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
@@ -146,6 +148,9 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
@@ -0,0 +1,207 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { AppMetaData } from "@yaakapp-internal/rpc-schema";
import { useAtomValue } from "jotai";
import { useKeyValue } from "../../hooks/useKeyValue";
import { appInfo } from "../../lib/appInfo";
import {
AGENT_CLIENTS,
type AgentClient,
type AgentSetup,
appSetup,
cliSetup,
} from "../../lib/agentSetup";
import { rpc } from "../../lib/rpc";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "../core/Button";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { SegmentedControl } from "../core/SegmentedControl";
import {
ModelSettingRowBoolean,
SettingRow,
SettingValue,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const MCP_URL = "http://127.0.0.1:64343/mcp";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
const SKILL_INSTALL_COMMAND = "yaak agent install";
export function SettingsAgents() {
const settings = useAtomValue(settingsAtom);
const { value: token } = useKeyValue<string | null>({
namespace: "no_sync",
key: "mcp_server_token",
fallback: null,
});
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
key: "agents_setup_client",
fallback: "claude_code",
});
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
send your requests.
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection
title="Yaak CLI"
description="Lets agents use Yaak from the command line, even with the app closed."
>
<SettingRow
title="Status"
description={
cliVersion.data == null
? "The Yaak CLI is not installed."
: `Yaak CLI ${cliVersion.data} is installed.`
}
controlClassName="gap-2"
>
<IconButton
size="sm"
icon="refresh"
title="Check again"
spin={cliVersion.isFetching}
onClick={() => cliVersion.refetch()}
/>
{cliVersion.data == null && (
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
rightSlot={<Icon icon="external_link" />}
>
Install CLI
</Button>
)}
</SettingRow>
{cliVersion.data == null ? (
<SettingRow title="Install" description="Install it with npm.">
<SettingValue value={CLI_INSTALL_COMMAND} />
</SettingRow>
) : (
<SettingRow
title="Agent skill"
description={
<>
Teach Claude Code, Cursor, Codex, and opencode to drive the{" "}
<InlineCode>yaak</InlineCode> command, wherever they're installed.
</>
}
>
<SettingValue value={SKILL_INSTALL_COMMAND} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="MCP server in the app"
description="For agents that connect over HTTP, or to act on the workspaces open in Yaak."
>
<ModelSettingRowBoolean
model={settings}
modelKey="mcpServerEnabled"
title="Enable MCP server"
description={
<>
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
</>
}
/>
{settings.mcpServerEnabled && token != null && (
<SettingRow
title="Token"
description="Agents send this as a bearer token."
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
>
<SettingValue value={token} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="Connect an agent"
description="Pick your agent or editor to see how to add Yaak's MCP tools to it."
>
<div className="py-4 border-b border-border-subtle">
<SegmentedControl
name="agents_setup_client"
label="Agent"
hideLabel
value={client}
onChange={setClient}
options={AGENT_CLIENTS}
/>
</div>
<SetupRow
title="Through the Yaak CLI"
description={
cliVersion.data == null
? "Recommended. Install the Yaak CLI above first."
: `Recommended. ${cliSetup(client).where}`
}
setup={cliSetup(client)}
/>
{settings.mcpServerEnabled && token != null ? (
<SetupRow
title="Through the app"
description={appSetup(client, MCP_URL, token).where}
setup={appSetup(client, MCP_URL, token)}
/>
) : (
<SettingRow
title="Through the app"
description="Turn on the MCP server above to connect this way."
>
<span />
</SettingRow>
)}
</SettingsSection>
</SettingsList>
</VStack>
);
}
function SetupRow({
title,
description,
setup,
}: {
title: string;
description: string;
setup: AgentSetup;
}) {
return (
<div className="py-4 border-b border-border-subtle">
<div className="text text-text">{title}</div>
<div className="mt-1 text-sm text-text-subtle">{description}</div>
<Editor
readOnly
hideGutter
wrapLines
heightMode="auto"
stateKey={null}
language={setup.language}
defaultValue={setup.snippet}
className="mt-3 border border-border-subtle rounded-md px-2"
actions={<CopyIconButton size="sm" variant="border" text={setup.snippet} title="Copy setup" />}
/>
</div>
);
}
@@ -1,4 +1,3 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
@@ -9,6 +8,7 @@ import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
@@ -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{" "}
@@ -141,7 +152,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
@@ -157,7 +168,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
@@ -9,7 +9,7 @@ import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
@@ -176,7 +176,7 @@ export function SettingsInterface() {
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
@@ -216,7 +216,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
await rpc("cmd_restart");
}}
>
Apply and Restart
@@ -1,17 +1,17 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export function SettingsLicense() {
return (
@@ -135,7 +135,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
@@ -151,7 +151,7 @@ function SettingsLicenseCmp() {
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
@@ -21,6 +20,7 @@ import {
TableHeaderCell,
TableRow,
useDebouncedValue,
CountBadge,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
@@ -32,13 +32,13 @@ import { showConfirmDelete } from "../../lib/confirm";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { CountBadge } from "../core/CountBadge";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { TabContent, Tabs } from "../core/Tabs/Tabs";
import { EmptyStateText } from "../EmptyStateText";
import { SelectFile } from "../SelectFile";
import { platform } from "@yaakapp-internal/platform";
interface SettingsPluginsProps {
defaultSubtab?: string;
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
icon="help"
title="View documentation"
onClick={() =>
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
}
/>
</HStack>
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { useRef } from "react";
import { openSettings } from "../commands/openSettings";
@@ -13,6 +12,7 @@ import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
import { platform } from "@yaakapp-internal/platform";
export function SettingsDropdown() {
const exportData = useExportData();
@@ -62,7 +62,7 @@ export function SettingsDropdown() {
{
label: "Create Run Button",
leftSlot: <Icon icon="rocket" />,
onSelect: () => openUrl("https://yaak.app/button/new"),
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
},
{ type: "separator", label: `Yaak v${appInfo.version}` },
{
@@ -78,26 +78,25 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/docs/cli"),
onSelect: () => openSettings.mutate("agents"),
},
{
label: "Feedback",
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/feedback"),
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
},
{
label: "Changelog",
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}
>
+27 -11
View File
@@ -43,7 +43,8 @@ import { getFolderActions } from "../hooks/useFolderActions";
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
import { useHotKey } from "../hooks/useHotKey";
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
import { useListenToTauriEvent } from "../hooks/useListenToTauriEvent";
import { platform } from "@yaakapp-internal/platform";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { getModelAncestors } from "../hooks/useModelAncestors";
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
@@ -68,7 +69,6 @@ import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import {
Icon,
LoadingIcon,
@@ -76,6 +76,7 @@ import {
isSelectedFamily,
selectedIdsFamily,
InlineCode,
HttpStatusTag,
} from "@yaakapp-internal/ui";
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
@@ -112,6 +113,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);
@@ -130,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
if (!didFocus) filterRef.current?.focus();
}, []);
// Focus any new sidebar models when created
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
if (!isSidebarLeafModel(payload.model)) return;
if (!(payload.change.type === "upsert" && payload.change.created)) return;
treeRef.current?.selectItem(payload.model.id, true);
// Focus new sidebar models created by the user in this window. Writes from other
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
// the selection.
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== platform.window.label) continue;
if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true);
}
});
useEffect(() => {
@@ -724,7 +732,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,
@@ -929,10 +941,14 @@ const sidebarTreeAtom = atom<
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
if (a.sortPriority !== b.sortPriority) {
return a.sortPriority - b.sortPriority;
}
return a.sortPriority - b.sortPriority;
// Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
});
for (const item of childItems) {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -1,4 +1,3 @@
import { readDir } from "@tauri-apps/plugin-fs";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
@@ -6,6 +5,7 @@ import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
import { platform } from "@yaakapp-internal/platform";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
@@ -24,7 +24,7 @@ export function SyncToFilesystemSetting({
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
const files = await platform.files.readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
<div className="overflow-y-auto h-full">
{name === "secure" ? (
<PlainInput
required
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<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
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form>
);
}
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
@@ -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,13 @@ 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";
@@ -38,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
interface Props {
style: CSSProperties;
@@ -54,15 +55,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 +81,35 @@ 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 hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(() => {
return [
@@ -119,31 +132,24 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
];
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
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 +159,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 +172,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -249,7 +256,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 +264,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 +276,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>
@@ -286,7 +293,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
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 +301,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>
@@ -1,5 +1,12 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import {
HStack,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
@@ -10,8 +17,6 @@ import {
setPinnedWebsocketConnectionId,
useWebsocketEvents,
} from "../hooks/usePinnedWebsocketConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { languageFromContentType } from "../lib/contentType";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
+4 -4
View File
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -40,6 +40,7 @@ import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout";
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
@@ -53,7 +54,7 @@ export function Workspace() {
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = type();
const osType = platform.osType();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
@@ -66,9 +67,8 @@ export function Workspace() {
return { background };
}, [activeEnvironment?.color]);
// We're loading still
if (workspaces.length === 0) {
return null;
return <RedirectToLatestWorkspace />;
}
const header = (
Loaded 100 of 897 files, more files were not shown because too many files have changed in this diff. Show more