Compare commits

..

193 Commits

Author SHA1 Message Date
Gregory Schier
a0442fb42b lint 2026-03-08 22:33:47 -07:00
Gregory Schier
12ece44197 Move Tree component to @yaakapp-internal/ui package
Decouple Tree from client app's hotkey system by adding
getSelectedItems() to TreeHandle and having callers register
hotkeys externally. Extract shared action callbacks to eliminate
duplication between hotkey and context menu handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:32:49 -07:00
Gregory Schier
4c041e68a9 Consolidate RPC commands into unified execute_action dispatcher
Replace individual RPC commands (proxy_start, proxy_stop) with a single
execute_action(ActionInvocation) handler. This simplifies the RPC interface
and enables action chaining through events for workflows like duplicate-then-navigate.
2026-03-08 19:04:31 -07:00
Gregory Schier
6534421733 Start extracting Tree component 2026-03-08 16:37:25 -07:00
Gregory Schier
6e11894f79 Lint stuff 2026-03-08 15:50:13 -07:00
Gregory Schier
96a22c68f2 Model store hooked up 2026-03-08 15:42:18 -07:00
Gregory Schier
0a616eb5e2 Got models and event system working 2026-03-08 15:18:31 -07:00
Gregory Schier
7382287bef Initial DB implementation 2026-03-08 14:39:00 -07:00
Gregory Schier
a5433fbc74 Remove unused yaak-proxy-models crate
Replaced by a purpose-built ProxyEntry model to be added to
yaak-proxy-lib, which better handles multi-protocol capture
(HTTP, gRPC, GraphQL, WebSocket) without REST-specific assumptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:38:16 -07:00
Gregory Schier
6f8c4c06bb Add transport-agnostic RPC layer for proxy app
Introduces yaak-rpc (shared RPC infrastructure) and yaak-proxy-lib
(proxy app logic decoupled from any frontend). A single Tauri command
dispatches all RPC calls through a typed router, with TypeScript types
auto-generated via ts-rs and a generic rpc() function for type-safe
calls from the frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:27:51 -07:00
Gregory Schier
4c37e62146 Start extracting DBContext 2026-03-08 08:56:08 -07:00
Gregory Schier
cf28229f5f New yaak-databases crate for shared core logic 2026-03-08 08:10:37 -07:00
Gregory Schier
3586c8fe24 Move Icon and LoadingIcon to shared package 2026-03-07 08:00:14 -08:00
Gregory Schier
d99898f39b Move some more stuff over 2026-03-07 07:44:50 -08:00
Gregory Schier
ff6686f982 HeaderSize as shared component 2026-03-07 07:32:58 -08:00
Gregory Schier
6f9e4ada15 Shared window crate 2026-03-07 06:50:11 -08:00
Gregory Schier
fd100330a6 Extract shared UI and theme packages 2026-03-06 10:30:31 -08:00
Gregory Schier
6915778c06 Refactor desktop app into separate client and proxy apps 2026-03-06 09:23:19 -08:00
Gregory Schier
e26705f016 Use separated client/proxy dev ports across worktrees 2026-03-06 09:20:49 -08:00
Gregory Schier
32f22aad67 Add initial yaak-proxy crate 2026-03-06 06:58:45 -08:00
Gregory Schier
88f5f0e045 Add redirect drop metadata and warning UI (#418) 2026-03-05 06:14:11 -08:00
Gregory Schier
615f3134d2 Fix plugin settings layout 2026-03-04 09:21:15 -08:00
Gregory Schier
0c7051d59c Better external OAuth callback format 2026-03-04 09:10:49 -08:00
Gregory Schier
30f006401a CLI plugin host: handle send/render HTTP requests (#415)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:41:53 -08:00
Gregory Schier
3c12074db6 Mention CLI in main dropdown 2026-03-03 06:50:25 -08:00
Gregory Schier
851f12f149 app: detect CLI availability and add command palette copy action 2026-03-02 14:55:04 -08:00
Gregory Schier
cc0d31fdbb cli: use update.yaak.app /cli/check for version checks 2026-03-02 14:52:23 -08:00
Gregory Schier
bab4fe899b Upgrade Yaak CLI 2026-03-02 08:01:02 -08:00
Gregory Schier
0b250ff5b5 Fix lint 2026-03-02 07:52:08 -08:00
Gregory Schier
fbf0473b20 Fix plugin dev rebuild signaling and reload toast timing 2026-03-02 07:02:28 -08:00
Gregory Schier
876b7ef454 Add top-level generate and publish CLI aliases 2026-03-02 06:28:04 -08:00
Gregory Schier
96e8572758 Add CLI update check and API client kind identity 2026-03-02 06:21:00 -08:00
Gregory Schier
f302dc39a2 Move local plugin install command into plugins_ext 2026-03-01 16:42:13 -08:00
Gregory Schier
2ca51125a4 Improve plugin source modeling and runtime dedup (#414) 2026-03-01 16:30:43 -08:00
Gregory Schier
2d99e26f19 plugin-events: route model/find requests through shared handler (#409) 2026-02-28 14:16:32 -08:00
Gregory Schier
da1e04d99e Fix Copy as gRPCurl with template-tag payloads (#413) 2026-02-28 07:39:44 -08:00
dependabot[bot]
d875eaa5bf Bump minimatch from 3.1.2 to 3.1.5 (#411)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 07:29:04 -08:00
Gregory Schier
5fa2469cd6 Some cleanup 2026-02-28 07:24:30 -08:00
Gregory Schier
49053cb423 Fix [object Object] request descriptions after OpenAPI import (#412) 2026-02-27 15:36:46 -08:00
Gregory Schier
37d0cabb22 fix: preserve drive letter in Windows plugin paths (#410)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:23:24 -08:00
Gregory Schier
435ee54140 docs: clarify app vs cli tag naming 2026-02-26 09:40:58 -08:00
dependabot[bot]
407f2c9921 Bump rollup from 4.55.1 to 4.59.0 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 09:31:25 -08:00
Gregory Schier
3a6630a14d tests(cli): prevent request send test teardown hang (#408) 2026-02-26 08:58:13 -08:00
Gregory Schier
ffc80d234c Remove noisy logs 2026-02-26 08:13:18 -08:00
Gregory Schier
644c683714 Update CLI package 2026-02-26 08:13:07 -08:00
Gregory Schier
d2c1bd79ac Fix chaining multiple requests together.
Fixes
https://yaak.app/feedback/posts/request-chaining-issue-on-cold-start
2026-02-26 08:12:54 -08:00
Gregory Schier
020589f2e6 fix: keep Send All response updates window-scoped (#405) 2026-02-25 06:54:59 -08:00
Gregory Schier
b2a70d8938 cli: prep 0.4.0 stable release 2026-02-23 08:55:16 -08:00
Gregory Schier
64c626ed30 Improve CLI streaming output, logging flags, and schema/help ergonomics 2026-02-23 08:06:41 -08:00
Gregory Schier
35d9ed901a Add workspace/environment schemas and shared agent hints 2026-02-23 08:06:41 -08:00
gschier
f04b34be1a Deploying to main from @ mountain-loop/yaak@1e7e1232da 🚀 2026-02-23 15:47:59 +00:00
Gregory Schier
1e7e1232da cli: compact schema output by default and tighten field docs 2026-02-22 16:18:29 -08:00
Gregory Schier
c31d477a90 cli: share HTTP helpers and improve schema guidance 2026-02-22 16:03:05 -08:00
Gregory Schier
443e1b8262 ci(cli): stamp Cargo version from release tag before build 2026-02-22 15:35:42 -08:00
Gregory Schier
c6b7cb2e32 ci(cli): use build script with SKIP_WASM_BUILD in release workflow 2026-02-22 15:26:21 -08:00
Gregory Schier
4aef826a80 Initialize plugins in PluginManager::new and fix CLI release deps 2026-02-22 15:06:55 -08:00
Gregory Schier
50c7992b42 Unify plugin bootstrap and prep vendored assets in CLI release 2026-02-22 15:01:34 -08:00
Gregory Schier
5e9aebda6f Embed CLI plugin assets and share bundled plugin registration 2026-02-22 14:44:40 -08:00
Gregory Schier
a1e84c7785 Bump @yaakapp/cli to 0.4.0-beta.2 2026-02-22 14:28:49 -08:00
Gregory Schier
fea4411afa Remove recursive API npm publish script 2026-02-22 14:21:07 -08:00
Gregory Schier
8315e4afad Add API release workflow and harden CLI npm publish 2026-02-22 14:11:26 -08:00
Gregory Schier
a19ee9b502 fix(cli-release): set npm dist-tag for prerelease publishes 2026-02-22 10:42:14 -08:00
Gregory Schier
0130bdee6f Install dbus build deps for Linux CLI release builds 2026-02-22 10:22:11 -08:00
Gregory Schier
71ae9f41ed Use npm trusted publishing for CLI release workflow 2026-02-22 10:10:06 -08:00
Gregory Schier
d06b6ce636 Merge plugin CLI into here (#404) 2026-02-22 10:06:24 -08:00
Gregory Schier
f5727b28c4 faker: render Date outputs as ISO strings 2026-02-21 07:24:07 -08:00
Gregory Schier
c62db7be06 Add contribution policy docs and PR checklist template 2026-02-20 14:09:59 -08:00
Gregory Schier
4e56daa555 CLI send enhancements and shared plugin event routing (#398) 2026-02-20 13:21:55 -08:00
dependabot[bot]
746bedf885 Bump hono from 4.11.7 to 4.11.10 (#403)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 09:01:23 -08:00
Gregory Schier
949c4a445a Fix NTLM challenge parsing when WWW-Authenticate has Negotiate first (#402) 2026-02-20 08:48:27 -08:00
Gregory Schier
1f588d0498 Fix live visibility for streaming HTTP responses (#401) 2026-02-20 07:15:55 -08:00
Gregory Schier
4573edc1e1 Restore send parity in shared HTTP pipeline (#400) 2026-02-19 14:36:45 -08:00
Gregory Schier
5a184c1b83 Fix OAuth token fetch failures from ad-hoc response persistence (#399) 2026-02-19 14:04:34 -08:00
Gregory Schier
7b73401dcf Show delete action for duplicate base environments 2026-02-19 06:17:38 -08:00
Gregory Schier
8571440d84 release: remove stale windows signatures before machine bundle 2026-02-18 16:51:38 -08:00
Gregory Schier
bc37a5d666 release: clean stale windows installers before machine bundle 2026-02-18 16:11:08 -08:00
Gregory Schier
a80f2ccf9a Port claude skills 2026-02-18 16:04:17 -08:00
Gregory Schier
1eaf276b75 release: include setup-machine signature artifact 2026-02-18 07:08:34 -08:00
Gregory Schier
e9559dfdfa Update release.yml 2026-02-18 06:40:09 -08:00
Gregory Schier
4c2e7b8609 Rename machine setup file for easier suffix matching 2026-02-18 06:36:59 -08:00
Gregory Schier
e638cecf07 release: add per-machine NSIS installer and X-Install-Mode updater header 2026-02-17 15:11:20 -08:00
Gregory Schier
076058da4f release: add per-machine NSIS installer for enterprise deployment 2026-02-17 14:49:54 -08:00
Gregory Schier
f1bc4aa146 Reapply "models: remove legacy template-function-faker plugin row"
This reverts commit 773c4a24a5.
2026-02-17 09:11:55 -08:00
Gregory Schier
773c4a24a5 Revert "models: remove legacy template-function-faker plugin row"
This reverts commit 6cc659e5c4.
2026-02-17 09:08:44 -08:00
Gregory Schier
6cc659e5c4 models: remove legacy template-function-faker plugin row 2026-02-17 08:52:35 -08:00
Gregory Schier
e1580210dc CLI command architecture and DB-backed model update syncing (#397) 2026-02-17 08:20:31 -08:00
Pathik
0a4ffde319 Support moving multiple requests to another workspace (#396)
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-02-16 08:42:42 -08:00
Gregory Schier
cc4d598af3 Update skill 2026-02-16 06:02:03 -08:00
Davide Becker
f5d11cb6d3 Add support for client assertions in the OAuth 2 plugin (#395)
Co-authored-by: Davide Becker <github@reg.davide.me>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-02-14 07:38:54 -08:00
Gregory Schier
65e91aec6b Fix git pull conflicts with pull.ff=only and improve commit UX (#394) 2026-02-13 14:46:47 -08:00
Gregory Schier
ae943a5fd2 Fix lint: ignore flatpak in biome, fix search cursor iterator usage 2026-02-12 15:34:13 -08:00
winit
9e1a11de0b Add CodeMirror extension to display find match count in the editor. (#390) 2026-02-12 15:27:18 -08:00
Gregory Schier
52732e12ec Fix license activation and plugin requests ignoring proxy settings (#393)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com
2026-02-12 14:38:53 -08:00
Gregory Schier
1127d7e3fa Use consistent release title format in generate-release-notes command 2026-02-11 17:38:13 -08:00
Gregory Schier
7d4d228236 Fix HTTP/2 requests failing with duplicate Content-Length (#391)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:11:35 -08:00
Gregory Schier
565e053ee8 Fix auth tab crash when template rendering fails (#392) 2026-02-11 14:59:17 -08:00
Gregory Schier
26aba6034f Move faker plugin back to external (plugins-external/faker) 2026-02-11 10:22:20 -08:00
Gregory Schier
9a1d613034 Fix RPM bundle path validation for metainfo file 2026-02-11 08:14:16 -08:00
Gregory Schier
3e4de7d3c4 Move build scripts to Flathub repo, keep release prep scripts here 2026-02-11 07:28:56 -08:00
Gregory Schier
b64b5ec0f8 Refresh Git dropdown data on open and fetch periodically
- Add refreshKey to useGit queries so dropdown data refreshes on open
- Convert fetchAll from mutation to query with 10-minute refetch interval
- Re-run status query after fetchAll completes via dataUpdatedAt key
- Use placeholderData to keep previous data during key changes
- Remove disabled state from Push, Pull, and Commit menu items
2026-02-11 07:20:53 -08:00
Gregory Schier
510d1c7d17 Remove Flatpak manifest (lives in Flathub repo) 2026-02-11 06:32:39 -08:00
Gregory Schier
ed13a62269 Use static desktop file and clean up manifest comments 2026-02-11 06:30:09 -08:00
Gregory Schier
935d613959 Move lockfile patch to standalone script 2026-02-10 23:35:14 -08:00
Gregory Schier
adeaaccc45 Add v2026.2.0 release to metainfo, simplify CI workflow
- Metainfo is managed upstream (updated before tagging)
- CI no longer modifies metainfo; just copies manifest and sources to Flathub
- Flathub manifest installs metainfo from git source
- Permissions reverted to read-only
2026-02-10 23:29:27 -08:00
Gregory Schier
d253093333 Revert "Simplify CI: metainfo releases only accumulate in Flathub repo"
This reverts commit f265b7a572.
2026-02-10 23:26:52 -08:00
Gregory Schier
f265b7a572 Simplify CI: metainfo releases only accumulate in Flathub repo
- Remove metainfo update from update-manifest.sh
- Remove CI step that committed metainfo back to app repo
- Revert permissions back to read-only
- CI now inserts release entry directly into Flathub repo's metainfo
2026-02-10 23:26:22 -08:00
Gregory Schier
68b2ff016f CI: rewrite metainfo paths for Flathub repo 2026-02-10 23:24:09 -08:00
Gregory Schier
a1c6295810 Clean up Flatpak manifest for v2026.2.0
- Update tag to v2026.2.0
- Use SKIP_WASM_BUILD env var instead of build-time package.json patch
- Install metainfo from git source (remove temporary type: file source)
- Fix fix-lockfile.mjs to skip workspace packages
- CI: commit metainfo releases back to app repo, bump permissions to write
2026-02-10 23:19:23 -08:00
Gregory Schier
76ee3fa61b Flatpak: build from source instead of repackaging debs (#389) 2026-02-10 23:05:33 -08:00
Gregory Schier
7fef35ce0a Ship metainfo in deb, remove from Flatpak manifest 2026-02-10 15:26:40 -08:00
Gregory Schier
654af09951 Bump GNOME runtime to 49, fix corrupted arm64 SHA256 2026-02-10 15:22:51 -08:00
Gregory Schier
484dcfade0 Add Flatpak and Flathub packaging support (#388) 2026-02-10 14:38:40 -08:00
Gregory Schier
fda18c5434 Snapshot faker template function names in test
Replace the brittle count assertion (toBe(226)) with a snapshot of all
exported function names. This catches accidental additions, removals,
or renames across faker upgrades with a clear diff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:22:03 -08:00
Gregory Schier
a8176d6e9e Skip disabled key-value entries during request rendering
Skip disabled headers, metadata, URL parameters, and form body
entries in the render phase for HTTP, gRPC, and WebSocket requests.
Previously, disabled entries were still template-rendered even though
they were filtered out later at the use site.
2026-02-09 10:17:43 -08:00
Gregory Schier
957d8d9d46 Move faker plugin from external to bundled 2026-02-09 08:43:49 -08:00
Gregory Schier
5f18bf25e2 Replace shell-quote with shlex for curl import (#387) 2026-02-09 08:22:11 -08:00
Gregory Schier
66942eaf2c Update httpsnippet plugin README and bump to v1.0.3 2026-02-07 15:20:20 -08:00
Zhizhen He
38796b1833 feat: add delete folder and copy id actions to folder settings (#380) 2026-02-07 08:48:38 -08:00
dependabot[bot]
49ffa6fc45 Bump bytes from 1.10.1 to 1.11.1 (#379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 08:48:03 -08:00
Brijmohan Siyag
1f56ba2eb6 Fix text input autocomplete not working with completionOptions (#368) 2026-02-07 08:47:50 -08:00
Gregory Schier
f98a70ecb4 Add dynamic() support to prompt.form() plugin API (#386) 2026-02-07 08:09:40 -08:00
dependabot[bot]
2984eb40c9 Bump time from 0.3.41 to 0.3.47 (#385)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 16:50:40 -08:00
Gregory Schier
cc5d4742f0 Don't select current request by default for response chaining 2026-02-05 13:08:08 -08:00
Gregory Schier
5b8e4b98a0 Use "send" preview mode for copy-as-curl 2026-02-05 08:31:28 -08:00
Gregory Schier
8637c90a21 Fix CSV responses ignoring raw view mode 2026-02-05 07:57:12 -08:00
dependabot[bot]
b88c5e71a0 Bump @modelcontextprotocol/sdk from 1.25.2 to 1.26.0 (#383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:54:01 -08:00
dependabot[bot]
1899d512ab Bump git2 from 0.20.2 to 0.20.4 (#384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:53:50 -08:00
Gregory Schier
7c31718f5e Git Improvements (#382) 2026-02-04 11:46:04 -08:00
Gregory Schier
8f1463e5d0 More cleanup 2026-02-04 06:45:55 -08:00
Gregory Schier
0dc8807808 Cleanup more unused functions 2026-02-04 06:44:13 -08:00
Gregory Schier
f24a159b8a Clean up unused functions 2026-02-04 06:28:45 -08:00
Zhizhen He
0b91d3aaff feat: add breadcrumbs to folder setting (#296) 2026-02-03 07:40:24 -08:00
Gregory Schier
431dc1c896 Adjust dev menu 2026-02-02 17:58:58 -08:00
Gregory Schier
bc8277b56b Fix header behavior on cross-origin redirects (#378)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:58:27 -08:00
gschier
0afed185d9 Deploying to main from @ mountain-loop/yaak@55cee00601 🚀 2026-02-02 15:46:27 +00:00
Gregory Schier
55cee00601 More reliable plugin runtime kill 2026-02-02 07:45:19 -08:00
Gregory Schier
b41a8e04cb Graceful oauth server shutdown 2026-02-02 07:31:55 -08:00
Gregory Schier
eff4519d91 Have cancellation work before the request is sent 2026-02-02 07:09:48 -08:00
Rahul Mishra
c4ce458f79 fix: pass down onClose properly (#376) 2026-01-31 07:34:40 -08:00
Gregory Schier
f02ae35634 Fix auth plugin dynamic form inputs broken after first request
The call_http_authentication_request handler was mutating auth.args with the result of applyDynamicFormInput(), which strips the dynamic callback functions. This permanently corrupted the plugin module's args, making all dynamic form controls (checkboxes, selects, etc.) unresponsive for that auth type after sending the first request.
2026-01-30 12:47:02 -08:00
Gregory Schier
c2f068970b Add external browser support for OAuth2 authorization (#375)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:49 -08:00
Gregory Schier
eec2d6bc38 Fix multipart tab value 2026-01-29 09:01:44 -08:00
Gregory Schier
efa22e470e Add diff viewer to git commit dialog (#374)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:50:56 -08:00
Gregory Schier
c00d2e981f Fix basic auth failing when password field is empty or unset
Handle undefined username/password values by defaulting to empty string,
preventing "undefined" from being encoded in the Authorization header.

Fixes https://feedback.yaak.app/p/strange-basic-auth-behaviour-in-202612

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:07:03 -08:00
Gregory Schier
9c45254952 Fix template tag theme colors 2026-01-28 13:08:22 -08:00
Gregory Schier
d031ff231a Bump plugin runtime types 2026-01-28 08:43:19 -08:00
Gregory Schier
f056894ddb Show full URL parts in Timeline debug view (#373)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:41:17 -08:00
dependabot[bot]
1b0315165f Bump hono from 4.11.4 to 4.11.7 (#372) 2026-01-28 08:37:10 -08:00
Gregory Schier
bd7e840a57 Fix x64 macOS build bundling wrong architecture binaries
Set YAAK_TARGET_ARCH before npm run bootstrap so vendor scripts
download the correct x64 binaries instead of arm64 ones.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:00:59 -08:00
Gregory Schier
8969748c3c Add option to disable encryption when key is forgotten (#371) 2026-01-26 15:40:02 -08:00
Gregory Schier
4e15ac10a6 Add folder CRUD operations to MCP server (#369)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:08:24 -08:00
Gregory Schier
47a3d44888 Git branch flow improvements (#370) 2026-01-26 14:45:51 -08:00
Gregory Schier
eb10910d20 Update HttpMethodTag.tsx 2026-01-22 06:03:04 -08:00
Gregory Schier
6ba83d424d Fix request method dropdown for GraphQL not showing HTTP method 2026-01-22 06:02:49 -08:00
Gregory Schier
beb47a6b6a Refactor default headers to be injected dynamically (#367) 2026-01-19 07:29:00 -08:00
Gregory Schier
1893b8f8dd Enable source maps for production builds (#366)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 05:12:26 -08:00
Gregory Schier
7a5bca7aae Add text version of the response Timeline tab 2026-01-15 08:14:21 -08:00
Gregory Schier
9a75bc2ae7 Update release notes command 2026-01-15 07:22:46 -08:00
dependabot[bot]
65514e3882 Bump hono from 4.11.3 to 4.11.4 (#364) 2026-01-15 07:18:27 -08:00
Gregory Schier
9ddaafb79f Fix tab focusability 2026-01-15 07:17:25 -08:00
Gregory Schier
de47ee19ec Fix authentication actions being called with unrendered args 2026-01-15 07:10:33 -08:00
Gregory Schier
ea730d0184 Fix clicking URL placeholder params not focusing value input
The PairEditor ref callback used strict equality to determine when all
rows were ready, but placeholder params (like :id) regenerate fresh IDs
on every keystroke, causing rowsRef to accumulate entries. Using >=
allows the ref to be set even when there are more registered rows than
current pairs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:28:09 -08:00
Gregory Schier
fe706998d4 Fix cursor style on template tags 2026-01-14 10:36:42 -08:00
Gregory Schier
99209e088f Consolidate tab persistence logic into Tabs component
- Move active tab persistence into Tabs component with storageKey + activeTabKey props
- Change value prop to defaultValue so callers don't manage tab state
- Add TabsRef with setActiveTab method for programmatic tab switching
- Restore request_pane.focus_tab listener for :param placeholder clicks
- Update all Tab consumers to use new pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:10 -08:00
Gregory Schier
3eb29ff2fe Fix gRPC schema refresh not invalidating cache
The skip_cache flag in services() called reflect(), but reflect() had its
own cache check that returned early. Simplified by removing skip_cache and
always invalidating the pool in cmd_grpc_reflect, since that command is
only called when fresh schema is needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:20:03 -08:00
Gregory Schier
b759003c83 Fix events from old connections showing in new connections
Events from previous WebSocket/gRPC connections and HTTP responses were
persisting in the store and displaying in new connections. Added filter
parameter to mergeModelsInStore that clears old events when switching
connections, plus render-time filtering as a safety net.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:58:32 -08:00
Gregory Schier
6cba38ac89 Strip empty headers before sending 2026-01-14 06:59:05 -08:00
Gregory Schier
ba8f85baaf Update feedback links 2026-01-14 06:45:45 -08:00
Gregory Schier
9970d5fa6f Fix lint issues 2026-01-13 09:32:52 -08:00
Gregory Schier
d550b42ca3 Add count badge to DNS tab and make workspace settings tabs reorderable 2026-01-13 09:24:56 -08:00
Gregory Schier
2e1f0cb53f Adjust tab list margins 2026-01-13 09:24:53 -08:00
Gregory Schier
eead422ada Fix HeadersEditor padding when no inherited headers 2026-01-13 09:24:48 -08:00
Gregory Schier
b5753da3b7 Fix dropdown opening on first click of inactive tab 2026-01-13 09:24:44 -08:00
Gregory Schier
ae2f2459e9 Improve EventViewer UX
- Separate selected item from panel open state (closing panel keeps selection)
- Scroll selected item into view when detail panel opens
- Enter/Space opens detail panel, Escape closes it
- Remove browser focus outline on scroll container
- Add prefix prop to EventDetailHeader for labels
- Make timestamp optional in EventViewerRow
- Add close button to EventDetailHeader
- Fix title truncation with min-w-0
- Consolidate HttpResponseTimeline title generation
- Add ID/event labels to SSE detail header
- Remove fake timestamp from SSE events

Closes https://feedback.yaak.app/p/feedback-on-sse-viewer-ux-in-yaak
2026-01-13 09:05:50 -08:00
Gregory Schier
306e6f358a feat: Add DNS timings and resolution overrides (#360)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:42:22 -08:00
Gregory Schier
822d52a57e Better logging for plugin timeouts 2026-01-13 07:26:32 -08:00
Gregory Schier
e665ce04df Fix plugins commands 2026-01-12 12:58:39 -08:00
Alex Coté
e4828e1b17 Fix README icon (#361) 2026-01-12 08:08:54 -08:00
Gregory Schier
42143249a2 Prevent Windows console window for yaaknode and yaakprotoc
Add new_xplatform_command() helper in yaak-common that creates a
tokio::process::Command with CREATE_NO_WINDOW flag set on Windows.

Also converts git commands to async for consistency.
2026-01-11 15:07:56 -08:00
Gregory Schier
72a7e6963d Separate entitlements for main app, yaaknode, and yaakprotoc 2026-01-11 14:05:47 -08:00
Gregory Schier
494e9efb64 Apply entitlements when signing vendored binaries 2026-01-11 14:03:35 -08:00
Gregory Schier
9fe077f598 Sign vendored binaries with hardened runtime on macOS 2026-01-11 10:14:39 -08:00
Gregory Schier
a6eca1cf2e Add Windows binary paths to tauri resources 2026-01-11 09:55:12 -08:00
Gregory Schier
31edd1013f Add missing bootstrap step to release workflow 2026-01-11 09:42:36 -08:00
Gregory Schier
28e9657ea5 Add EventDetailHeader component and fix EventViewer overflow
- Create standardized EventDetailHeader with title, timestamp, actions, and copyText props
- Fix EventViewer firstSlot overflow/scrolling issue
- Update GrpcResponsePane, WebsocketResponsePane, HttpResponseTimeline, and EventStreamViewer to use EventDetailHeader
- Fix Timeline title consistency when toggling Raw/Formatted views
2026-01-11 08:51:36 -08:00
Gregory Schier
ff084a224a Consolidate event viewer interfaces (#355) 2026-01-11 07:57:05 -08:00
Gregory Schier
bbcae34575 Fix race condition where streamed events could be lost
Events stream in via model_write listener while also being fetched
from the database. If the DB fetch completed before all events were
persisted, replaceModelsInStore would wipe out events that came in
via model_write.

Added mergeModelsInStore that adds fetched events without removing
existing ones. Applied to HTTP, gRPC, and WebSocket event hooks.
2026-01-11 07:42:04 -08:00
Gregory Schier
2a5587c128 Show sent/received cookie counts in Cookies tab
- Add getCookieCounts function to parse cookie headers and count
  individual cookies (not just headers)
- Deduplicates by cookie name using Sets
- Display as sent/received format like Headers tab
- Add showZero to CountBadge so 0/3 displays properly
- Add tests for getCookieCounts
2026-01-11 07:20:01 -08:00
Gregory Schier
c41e173a63 Fix dropdown menu hotkeys not working when menu is closed
The nested menu PR introduced an early return null when !isOpen,
which prevented MenuItemHotKey components from being rendered.
Fixed by extracting hotKeyElements and rendering them even when
the menu is closed.
2026-01-11 07:19:56 -08:00
Gregory Schier
2b43407ddf Fix gRPC autocomplete schema not being applied
Two issues fixed:

1. Initialize stateExtensions with empty object {} instead of undefined.
   When called with no argument, the schema state was undefined, causing
   jsonCompletion() to return [] instead of a proper result object, which
   CodeMirror's autocomplete didn't handle correctly.

2. Change editorView from useRef to useState so the effect that calls
   updateSchema() properly re-runs when the editor view is set. With useRef,
   the effect could run before the editor was mounted or with a stale
   reference when the editor was recreated.
2026-01-10 14:57:28 -08:00
Gregory Schier
4d75b8ef06 Surface gRPC message deserialization errors to UI
Previously, when a gRPC streaming message failed to deserialize (e.g., wrong
type like int instead of string), the error was silently logged and the message
was dropped. Now errors are surfaced to the UI as GrpcEventType::Error events.

Changed the streaming/client_streaming methods to accept an on_message callback
that handles both success (logs ClientMessage) and error (logs Error) cases,
rather than logging the client message prematurely before deserialization.
2026-01-10 14:57:28 -08:00
Gregory Schier
aa79fb05f9 Fix gRPC stream panic: use async stream combinators instead of block_on
The gRPC streaming code was using tokio::runtime::Handle::current().block_on()
inside filter_map closures, which caused a panic ('Cannot start a runtime from
within a runtime') when called from an async context.

Fixed by replacing the pattern with .then(async move { ... }).filter_map(|x| x)
which properly handles async operations in stream pipelines.

This fixes the gRPC Ping/Pong freeze issue and restores request cancellation.
2026-01-10 14:57:28 -08:00
Gregory Schier
fe01796536 feat: add ctx.prompt.form() plugin API for multi-field form dialogs (#359) 2026-01-10 08:55:43 -08:00
914 changed files with 28552 additions and 8322 deletions

View File

@@ -6,14 +6,14 @@ Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core R
## Project Structure
```
crates/ # Core crates - should NOT depend on Tauri
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
crates-cli/ # CLI crate (yaak-cli)
```
## Completed Work
### 1. Folder Restructure
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
- Created `crates-cli/yaak-cli/` for the standalone CLI
@@ -43,13 +43,13 @@ crates-cli/ # CLI crate (yaak-cli)
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
4. Initialize managers in yaak-app's `.setup()` block
5. Remove `tauri` from Cargo.toml dependencies
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
## Key Files
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
@@ -68,5 +68,5 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
## Testing
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
- Run `npm run app-dev` to test the Tauri app still works
- Run `npm run client:dev` to test the Tauri app still works
- Run `cargo run -p yaak-cli -- --help` to test the CLI

View File

@@ -1,51 +0,0 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
---
Review a GitHub pull request in a new git worktree.
## Usage
```
/review-pr <PR_NUMBER>
```
## What to do
1. List all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. Extract the branch name from the PR
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
6. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
7. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
## Example Output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123
Branch: feature-auth
Ports: Vite (1421), MCP (64344)
To start working:
cd ../yaak-worktrees/pr-123
npm run app-dev
To remove when done:
git worktree remove ../yaak-worktrees/pr-123
```
## Error Handling
- If the PR doesn't exist, show a helpful error
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
- If `gh` CLI is not available, inform the user to install it

View File

@@ -37,3 +37,13 @@ The skill generates markdown-formatted release notes following this structure:
**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
## 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".

View File

@@ -1,35 +0,0 @@
# Worktree Management Skill
## Creating Worktrees
When creating git worktrees for this project, ALWAYS use the path format:
```
../yaak-worktrees/<NAME>
```
For example:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## What Happens Automatically
The post-checkout hook will automatically:
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
2. Copy gitignored editor config folders (.zed, .idea, etc.)
3. Run `npm install && npm run bootstrap`
## Deleting Worktrees
```bash
git worktree remove ../yaak-worktrees/<NAME>
```
## Port Assignments
- Main worktree: 1420 (Vite), 64343 (MCP)
- First worktree: 1421, 64344
- Second worktree: 1422, 64345
- etc.
Each worktree can run `npm run app-dev` simultaneously without conflicts.

View File

@@ -0,0 +1,48 @@
---
name: release-generate-release-notes
description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
---
# Generate Release Notes
Generate formatted markdown release notes for a Yaak tag.
## Workflow
1. Determine target tag.
2. Determine previous comparable tag:
- Beta tag: compare against previous beta (if the root version is the same) or stable tag.
- Stable tag: compare against previous stable tag.
3. Collect commits in range:
- `git log --oneline <prev_tag>..<target_tag>`
4. For linked PRs, fetch metadata:
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`)
- Plugin install links or other notable context
6. Format notes using Yaak style:
- Changelog badge at top
- Bulleted items with PR links where available
- Feedback links where available
- Full changelog compare link at bottom
## Formatting Rules
- Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence.
- Output the markdown code block last.
- Do not append `by @gschier` for PRs authored by `@gschier`.
## Release Creation Prompt
After producing notes, ask whether to create a draft GitHub release.
If confirmed and release does not yet exist, run:
`gh release create <tag> --draft --prerelease --title "Release <version_without_v>" --notes '<release notes>'`
If a draft release for the tag already exists, update it instead:
`gh release edit <tag> --title "Release <version_without_v>" --notes-file <path_to_notes>`
Use title format `Release <version_without_v>`, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.

4
.gitattributes vendored
View File

@@ -1,5 +1,5 @@
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true

18
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,18 @@
## Summary
<!-- Describe the bug and the fix in 1-3 sentences. -->
## Submission
- [ ] This PR is a bug fix or small-scope improvement.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement):
<!-- https://yaak.app/feedback/... -->
## Related
<!-- Link related issues, discussions, or feedback items. -->

52
.github/workflows/flathub.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Update Flathub
on:
release:
types: [published]
permissions:
contents: read
jobs:
update-flathub:
name: Update Flathub manifest
runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps:
- name: Checkout app repo
uses: actions/checkout@v4
- name: Checkout Flathub repo
uses: actions/checkout@v4
with:
repository: flathub/app.yaak.Yaak
token: ${{ secrets.FLATHUB_TOKEN }}
path: flathub-repo
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install source generators
run: |
pip install flatpak-node-generator tomlkit aiohttp
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub
working-directory: flathub-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}"
git push

59
.github/workflows/release-api-npm.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Release API to NPM
on:
push:
tags: [yaak-api-*]
workflow_dispatch:
inputs:
version:
description: API version to publish (for example 0.9.0 or v0.9.0)
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
name: Publish @yaakapp/api
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm ci
- name: Set @yaakapp/api version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-api-}"
fi
VERSION="${VERSION#v}"
echo "Preparing @yaakapp/api version: $VERSION"
cd packages/plugin-runtime-types
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Build @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm run build
- name: Publish @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm publish --provenance --access public

179
.github/workflows/release-app.yml vendored Normal file
View File

@@ -0,0 +1,179 @@
name: Release App Artifacts
on:
push:
tags: [v*]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin"
yaak_arch: "arm64"
os: "macos"
targets: "aarch64-apple-darwin"
- platform: "macos-latest" # for Intel-based Macs.
args: "--target x86_64-apple-darwin"
yaak_arch: "x64"
os: "macos"
targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04"
args: ""
yaak_arch: "x64"
os: "ubuntu"
targets: ""
- platform: "ubuntu-22.04-arm"
args: ""
yaak_arch: "arm64"
os: "ubuntu"
targets: ""
- platform: "windows-latest"
args: ""
yaak_arch: "x64"
os: "windows"
targets: ""
# Windows ARM64
- platform: "windows-latest"
args: "--target aarch64-pc-windows-msvc"
yaak_arch: "arm64"
os: "windows"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: 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
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install trusted-signing-cli (Windows only)
if: matrix.os == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = "$env:USERPROFILE\trusted-signing"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
$exe = Join-Path $dir "trusted-signing-cli.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- name: Sign vendored binaries (macOS only)
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime and their specific entitlements
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app-client/tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows'
shell: pwsh
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app-client/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber

218
.github/workflows/release-cli-npm.yml vendored Normal file
View File

@@ -0,0 +1,218 @@
name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
workflow_dispatch:
inputs:
version:
description: CLI version to publish (for example 0.4.0 or v0.4.0)
required: true
type: string
permissions:
contents: read
jobs:
prepare-vendored-assets:
name: Prepare vendored plugin assets
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Build plugin assets
env:
SKIP_WASM_BUILD: "1"
run: |
npm run build
npm run vendor:vendor-plugins
- name: Upload vendored assets
uses: actions/upload-artifact@v4
with:
name: vendored-assets
path: |
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app-client/vendored/plugins
if-no-files-found: error
build-binaries:
name: Build ${{ matrix.pkg }}
needs: prepare-vendored-assets
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- pkg: cli-darwin-arm64
runner: macos-latest
target: aarch64-apple-darwin
binary: yaak
- pkg: cli-darwin-x64
runner: macos-latest
target: x86_64-apple-darwin
binary: yaak
- pkg: cli-linux-arm64
runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
binary: yaak
- pkg: cli-linux-x64
runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
binary: yaak
- pkg: cli-win32-arm64
runner: windows-latest
target: aarch64-pc-windows-msvc
binary: yaak.exe
- pkg: cli-win32-x64
runner: windows-latest
target: x86_64-pc-windows-msvc
binary: yaak.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: release-cli-npm
cache-on-failure: true
- name: Install Linux build dependencies
if: startsWith(matrix.runner, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
- name: Download vendored assets
uses: actions/download-artifact@v4
with:
name: vendored-assets
path: crates-tauri/yaak-app-client/vendored
- name: Set CLI build version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build yaak
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
- name: Stage binary artifact
shell: bash
run: |
set -euo pipefail
mkdir -p "npm/dist/${{ matrix.pkg }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.pkg }}
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
if-no-files-found: error
publish-npm:
name: Publish @yaakapp/cli packages
needs: build-binaries
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Download binary artifacts
uses: actions/download-artifact@v4
with:
pattern: cli-*
path: npm/dist
merge-multiple: false
- name: Prepare npm packages
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
PRERELEASE="${VERSION#*-}"
NPM_TAG="${PRERELEASE%%.*}"
else
NPM_TAG="latest"
fi
echo "Preparing CLI npm packages for version: $VERSION"
echo "Publishing with npm dist-tag: $NPM_TAG"
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
- name: Publish @yaakapp/cli-darwin-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-arm64
- name: Publish @yaakapp/cli-darwin-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-x64
- name: Publish @yaakapp/cli-linux-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-arm64
- name: Publish @yaakapp/cli-linux-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-x64
- name: Publish @yaakapp/cli-win32-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-arm64
- name: Publish @yaakapp/cli-win32-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-x64
- name: Publish @yaakapp/cli
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli

View File

@@ -1,129 +0,0 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: '--target aarch64-apple-darwin'
yaak_arch: 'arm64'
os: 'macos'
targets: 'aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel-based Macs.
args: '--target x86_64-apple-darwin'
yaak_arch: 'x64'
os: 'macos'
targets: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
yaak_arch: 'x64'
os: 'ubuntu'
targets: ''
- platform: 'ubuntu-22.04-arm'
args: ''
yaak_arch: 'arm64'
os: 'ubuntu'
targets: ''
- platform: 'windows-latest'
args: ''
yaak_arch: 'x64'
os: 'windows'
targets: ''
# Windows ARM64
- platform: 'windows-latest'
args: '--target aarch64-pc-windows-msvc'
yaak_arch: 'arm64'
os: 'windows'
targets: 'aarch64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: 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
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install trusted-signing-cli (Windows only)
if: matrix.os == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = "$env:USERPROFILE\trusted-signing"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
$exe = Join-Path $dir "trusted-signing-cli.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: true
args: '${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json'

16
.gitignore vendored
View File

@@ -39,8 +39,22 @@ codebook.toml
target
# Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app/tauri.worktree.conf.json
crates-tauri/yaak-app-client/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
# Tauri auto-generated permission files
**/permissions/autogenerated
**/permissions/schemas
# Flatpak build artifacts
flatpak-repo/
.flatpak-builder/
flatpak/flatpak-builder-tools/
flatpak/cargo-sources.json
flatpak/node-sources.json
# Local Codex desktop env state
.codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json

2
AGENTS.md Normal file
View File

@@ -0,0 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval

16
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,16 @@
# Contributing to Yaak
Yaak accepts community pull requests for:
- Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
## Development Setup
For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).

2758
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
[workspace]
resolver = "2"
members = [
"crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -15,14 +19,20 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app",
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
]
[workspace.dependencies]
@@ -33,6 +43,7 @@ log = "0.4.29"
reqwest = "0.12.20"
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
@@ -44,8 +55,13 @@ thiserror = "2.0.17"
tokio = "1.48.0"
ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
@@ -58,12 +74,18 @@ yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" }
yaak-api = { path = "crates/yaak-api" }
yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
strip = false

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
</a>
</p>
@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- 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;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -58,13 +58,15 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy
Yaak is open source but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
> [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Documentation](https://yaak.app/docs)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

View File

@@ -3,23 +3,30 @@ 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';
export const moveToWorkspace = createFastMutation({
mutationKey: ['move_workspace'],
mutationFn: async (request: HttpRequest | GrpcRequest | WebsocketRequest) => {
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return;
if (requests.length === 0) return;
const title =
requests.length === 1
? 'Move Request'
: `Move ${pluralizeCount('Request', requests.length)}`;
showDialog({
id: 'change-workspace',
title: 'Move Workspace',
title,
size: 'sm',
render: ({ hide }) => (
<MoveToWorkspaceDialog
onDone={hide}
request={request}
requests={requests}
activeWorkspaceId={activeWorkspaceId}
/>
),

View File

@@ -1,21 +1,14 @@
import { getModel } from '@yaakapp-internal/models';
import { Icon } from '../components/core/Icon';
import { HStack } from '../components/core/Stacks';
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
const folder = getModel('folder', folderId);
if (folder == null) return;
showDialog({
id: 'folder-settings',
title: (
<HStack space={2} alignItems="center">
<Icon icon="folder_cog" size="xl" color="secondary" />
{resolvedModelName(folder)}
</HStack>
),
title: null,
size: 'lg',
className: 'h-[50rem]',
noPadding: true,

View File

@@ -0,0 +1,161 @@
import { open } from '@tauri-apps/plugin-dialog';
import { gitClone } from '@yaakapp-internal/git';
import { useState } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { appInfo } from '../lib/appInfo';
import { showErrorToast } from '../lib/toast';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { promptCredentials } from './git/credentials';
interface Props {
hide: () => void;
}
// Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string {
return path.includes('\\') ? '\\' : '/';
}
export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>('');
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>('');
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
const directory = directoryOverride ?? computedDirectory;
const workspaceDirectory =
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
title: 'Select Directory',
directory: true,
multiple: false,
});
if (dir != null) {
setBaseDirectory(dir);
setDirectoryOverride(null);
}
};
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url || !directory) return;
setIsCloning(true);
setError(null);
try {
const result = await gitClone(url, directory, promptCredentials);
if (result.type === 'needs_credentials') {
setError(
result.error ?? 'Authentication failed. Please check your credentials and try again.',
);
return;
}
// Open the workspace from the cloned directory (or subdirectory)
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
hide();
} catch (err) {
setError(String(err));
showErrorToast({
id: 'git-clone-error',
title: 'Clone Failed',
message: String(err),
});
} finally {
setIsCloning(false);
}
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
</Banner>
)}
<PlainInput
required
label="Repository URL"
placeholder="https://github.com/user/repo.git"
defaultValue={url}
onChange={setUrl}
/>
<PlainInput
label="Directory"
placeholder={appInfo.defaultProjectDir}
defaultValue={directory}
onChange={setDirectoryOverride}
rightSlot={
<IconButton
size="xs"
className="mr-0.5 !h-auto my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
/>
}
/>
<Checkbox
checked={hasSubdirectory}
onChange={setHasSubdirectory}
title="Workspace is in a subdirectory"
help="Enable if the Yaak workspace files are not at the root of the repository"
/>
{hasSubdirectory && (
<PlainInput
label="Subdirectory"
placeholder="path/to/workspace"
defaultValue={subdirectory}
onChange={setSubdirectory}
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? 'Cloning...' : 'Clone Repository'}
</Button>
</VStack>
);
}
function extractRepoName(url: string): string {
// Handle various Git URL formats:
// https://github.com/user/repo.git
// git@github.com:user/repo.git
// https://github.com/user/repo
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match?.[1]) {
return match[1];
}
// Fallback for SSH-style URLs
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
if (sshMatch?.[1]) {
return sshMatch[1];
}
return '';
}

View File

@@ -21,7 +21,7 @@ import { useActiveRequest } from '../hooks/useActiveRequest';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { useAllRequests } from '../hooks/useAllRequests';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Icon, useDebouncedState } from '@yaakapp-internal/ui';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
import type { HotkeyAction } from '../hooks/useHotKey';
@@ -32,6 +32,8 @@ import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { appInfo } from '../lib/appInfo';
import { copyToClipboard } from '../lib/copy';
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { showDialog } from '../lib/dialog';
@@ -48,7 +50,6 @@ import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { Hotkey } from './core/Hotkey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
interface CommandPaletteGroup {
@@ -162,6 +163,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
label: 'Send Request',
onSelect: () => sendRequest(activeRequest.id),
});
if (appInfo.cliVersion != null) {
commands.push({
key: 'request.copy_cli_send',
searchText: `copy cli send yaak request send ${activeRequest.id}`,
label: 'Copy CLI Send Command',
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
});
}
httpRequestActions.forEach((a, i) => {
commands.push({
key: `http_request_action.${i}`,

View File

@@ -9,7 +9,7 @@ import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';

View File

@@ -0,0 +1,181 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useId, useMemo } from 'react';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
interface Props {
workspace: Workspace;
}
interface DnsOverrideWithId extends DnsOverride {
_id: string;
}
export function DnsOverridesEditor({ workspace }: Props) {
const reactId = useId();
// Ensure each override has an internal ID for React keys
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
return workspace.settingDnsOverrides.map((override, index) => ({
...override,
_id: `${reactId}-${index}`,
}));
}, [workspace.settingDnsOverrides, reactId]);
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides });
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: '',
ipv4: [''],
ipv6: [],
enabled: true,
};
handleChange([...workspace.settingDnsOverrides, newOverride]);
}, [workspace.settingDnsOverrides, handleChange]);
const handleUpdate = useCallback(
(index: number, update: Partial<DnsOverride>) => {
const updated = workspace.settingDnsOverrides.map((o, i) =>
i === index ? { ...o, ...update } : o,
);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
const handleDelete = useCallback(
(index: number) => {
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
but only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Hostname</TableHeaderCell>
<TableHeaderCell>IPv4 Address</TableHeaderCell>
<TableHeaderCell>IPv6 Address</TableHeaderCell>
<TableHeaderCell className="w-10" />
</TableRow>
</TableHead>
<TableBody>
{overridesWithIds.map((override, index) => (
<DnsOverrideRow
key={override._id}
override={override}
onUpdate={(update) => handleUpdate(index, update)}
onDelete={() => handleDelete(index)}
/>
))}
</TableBody>
</Table>
)}
<HStack>
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
Add DNS Override
</Button>
</HStack>
</VStack>
);
}
interface DnsOverrideRowProps {
override: DnsOverride;
onUpdate: (update: Partial<DnsOverride>) => void;
onDelete: () => void;
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', ');
const ipv6Value = override.ipv6.join(', ');
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="Hostname"
placeholder="api.example.com"
defaultValue={override.hostname}
onChange={(hostname) => onUpdate({ hostname })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv4 addresses"
placeholder="127.0.0.1"
defaultValue={ipv4Value}
onChange={(value) =>
onUpdate({
ipv4: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv6 addresses"
placeholder="::1"
defaultValue={ipv6Value}
onChange={(value) =>
onUpdate({
ipv6: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<IconButton
size="xs"
iconSize="sm"
icon="trash"
title="Delete override"
onClick={onDelete}
/>
</TableCell>
</TableRow>
);
}

View File

@@ -83,7 +83,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
className,
...props
}: FormInputsProps<T> & { className?: string}) {
}: FormInputsProps<T> & { className?: string }) {
return (
<VStack
space={3}
@@ -198,6 +198,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/>
);
case 'accordion':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div key={i + stateKey}>
<DetailsBanner
@@ -219,6 +222,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div>
);
case 'h_stack':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
<FormInputs
@@ -233,6 +239,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div>
);
case 'banner':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<Banner
key={i + stateKey}
@@ -308,7 +317,7 @@ function TextArg({
autocompleteFunctions,
autocompleteVariables,
};
if (autocompleteVariables || autocompleteFunctions) {
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
return <Input {...props} />;
}
return <PlainInput {...props} />;
@@ -351,8 +360,9 @@ function EditorArg({
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
'max-h-[10rem]', // So it doesn't take up too much space
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
<Editor
id={id}
@@ -603,3 +613,19 @@ function KeyValueArg({
</div>
);
}
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
for (const input of inputs) {
if ('inputs' in input && !hasVisibleInputs(input.inputs)) {
// Has children, but none are visible
return false;
}
if (!input.hidden) {
return true;
}
}
return false;
}

View File

@@ -8,7 +8,7 @@ import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
type Props = {

View File

@@ -1,13 +1,14 @@
import type { Environment, Workspace } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import {
environmentsBreakdownAtom,
useEnvironmentsBreakdown,
} from '../hooks/useEnvironmentsBreakdown';
import { useHotKey } from '../hooks/useHotKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
@@ -15,19 +16,25 @@ import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { ContextMenu } from './core/Dropdown';
import { Icon, Tree } from '@yaakapp-internal/ui';
import type { TreeNode, TreeHandle, TreeProps } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import type { PairEditorHandle } from './core/PairEditor';
import { SplitLayout } from './core/SplitLayout';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
import { atomFamily } from 'jotai/utils';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
const collapsedFamily = atomFamily((treeId: string) => {
const key = ['env_collapsed', treeId ?? 'n/a'];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
interface Props {
initialEnvironmentId: string | null;
setRef?: (ref: PairEditorHandle | null) => void;
@@ -129,44 +136,43 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const actions = {
'sidebar.selected.rename': {
enable,
allowDefault: true,
priority: 100,
cb: async (items: TreeModel[]) => {
const item = items[0];
if (items.length === 1 && item != null) {
treeRef.current?.renameItem(item.id);
}
},
},
'sidebar.selected.delete': {
priority: 100,
enable,
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
},
'sidebar.selected.duplicate': {
priority: 100,
enable,
cb: async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) {
const item = items[0];
const newId = await duplicateModel(item);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
},
} as const;
return actions;
const getSelectedTreeModels = useCallback(
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
[],
);
const handleRenameSelected = useCallback(() => {
const items = getSelectedTreeModels();
if (items?.length === 1 && items[0] != null) {
treeRef.current?.renameItem(items[0].id);
}
}, [getSelectedTreeModels]);
const handleDeleteSelected = useCallback(
(items: TreeModel[]) => deleteModelWithConfirm(items),
[],
);
const handleDuplicateSelected = useCallback(async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) {
const newId = await duplicateModel(items[0]);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
}, [setSelectedEnvironmentId]);
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
useHotKey('sidebar.selected.rename', handleRenameSelected, { enable: treeHasFocus, allowDefault: true, priority: 100 });
useHotKey('sidebar.selected.delete', useCallback(() => {
const items = getSelectedTreeModels();
if (items) handleDeleteSelected(items);
}, [getSelectedTreeModels, handleDeleteSelected]), { enable: treeHasFocus, priority: 100 });
useHotKey('sidebar.selected.duplicate', useCallback(async () => {
const items = getSelectedTreeModels();
if (items) await handleDuplicateSelected(items);
}, [getSelectedTreeModels, handleDuplicateSelected]), { enable: treeHasFocus, priority: 100 });
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => {
@@ -184,6 +190,9 @@ function EnvironmentEditDialogSidebar({
}
const singleEnvironment = items.length === 1;
const canDeleteEnvironment =
isSubEnvironment(environment) ||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
const menuItems: DropdownItem[] = [
{
@@ -192,12 +201,10 @@ function EnvironmentEditDialogSidebar({
hidden: isBaseEnvironment(environment) || !singleEnvironment,
hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true,
onSelect: async () => {
onSelect: () => {
// Not sure why this is needed, but without it the
// edit input blurs immediately after opening.
requestAnimationFrame(() => {
actions['sidebar.selected.rename'].cb(items);
});
requestAnimationFrame(() => handleRenameSelected());
},
},
{
@@ -206,7 +213,7 @@ function EnvironmentEditDialogSidebar({
hidden: isBaseEnvironment(environment),
hotKeyAction: 'sidebar.selected.duplicate',
hotKeyLabelOnly: true,
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
onSelect: () => handleDuplicateSelected(items),
},
{
label: environment.color ? 'Change Color' : 'Assign Color',
@@ -228,9 +235,7 @@ function EnvironmentEditDialogSidebar({
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden:
(isBaseEnvironment(environment) && baseEnvironments.length <= 1) ||
!isSubEnvironment(environment),
hidden: !canDeleteEnvironment,
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
@@ -244,7 +249,7 @@ function EnvironmentEditDialogSidebar({
return menuItems;
},
[actions, baseEnvironments.length, handleDeleteEnvironment],
[baseEnvironments.length, handleDeleteEnvironment, setSelectedEnvironmentId],
);
const handleDragEnd = useCallback(async function handleDragEnd({
@@ -291,6 +296,13 @@ function EnvironmentEditDialogSidebar({
[setSelectedEnvironmentId],
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>['renderContextMenu']>>(
({ items, position, onClose }) => (
<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 ">
@@ -299,10 +311,11 @@ function EnvironmentEditDialogSidebar({
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
hotkeys={hotkeys}
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}

View File

@@ -15,9 +15,8 @@ import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { Icon, LoadingIcon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';

View File

@@ -0,0 +1,210 @@
import {
createWorkspaceModel,
foldersAtom,
patchModel,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Fragment, useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
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 { Icon } from '@yaakapp-internal/ui';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { EnvironmentEditor } from './EnvironmentEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
folderId: string | null;
tab?: FolderSettingsTab;
}
const TAB_AUTH = 'auth';
const TAB_HEADERS = 'headers';
const TAB_VARIABLES = 'variables';
const TAB_GENERAL = 'general';
export type FolderSettingsTab =
| typeof TAB_AUTH
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === 'folder' && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
return [
{
value: TAB_GENERAL,
label: 'General',
},
...headersTab,
...authTab,
{
value: TAB_VARIABLES,
label: 'Variables',
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
];
}, [authTab, folder, headersTab, numVars]);
if (folder == null) return null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon
icon="chevron_right"
size="lg"
className="opacity-50 flex-shrink-0"
/>
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
</span>
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)}
<span
className="whitespace-nowrap"
title={folder.name}
>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog('folder-settings');
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Folder
</Button>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
stateExtensions,
updateSchema,
} from 'codemirror-json-schema';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
@@ -39,15 +39,15 @@ export function GrpcEditor({
protoFiles,
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const [editorView, setEditorView] = useState<EditorView | null>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
editorViewRef.current = h;
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorViewRef.current == null ||
editorView == null ||
services === null ||
request.service === null ||
request.method === null
@@ -91,7 +91,7 @@ export function GrpcEditor({
}
try {
updateSchema(editorViewRef.current, JSON.parse(schema));
updateSchema(editorView, JSON.parse(schema));
} catch (err) {
showAlert({
id: 'grpc-parse-schema-error',
@@ -107,7 +107,7 @@ export function GrpcEditor({
),
});
}
}, [services, request.method, request.service]);
}, [editorView, services, request.method, request.service]);
const extraExtensions = useMemo(
() => [
@@ -118,7 +118,7 @@ export function GrpcEditor({
jsonLanguage.data.of({
autocomplete: jsonCompletion(),
}),
stateExtensions(/** Init with empty schema **/),
stateExtensions({}),
],
[],
);

View File

@@ -6,7 +6,7 @@ import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
import { pluralizeCount } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { Link } from './core/Link';

View File

@@ -7,12 +7,11 @@ import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { RadioDropdown } from './core/RadioDropdown';
@@ -69,11 +68,6 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
const inheritedHeaders = useInheritedHeaders(activeRequest);
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'grpcRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -145,14 +139,6 @@ export function GrpcRequestPane({
[activeRequest.description, authTab, metadataTab],
);
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest],
@@ -265,12 +251,11 @@ export function GrpcRequestPane({
</HStack>
</div>
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs_order"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
<TabContent value="message">
<GrpcEditor

View File

@@ -0,0 +1,249 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
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 { Icon, LoadingIcon, type IconProps } from '@yaakapp-internal/ui';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
}
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const connections = useAtomValue(activeGrpcConnections);
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const events = useGrpcEvents(activeConnection?.id ?? null);
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const activeEvent = useMemo(
() => (activeEventIndex != null ? events[activeEventIndex] : null),
[activeEventIndex, events],
);
// Set the active message to the first message received if unary
// biome-ignore lint/correctness/useExhaustiveDependencies: none
useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
return;
}
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
}, [events.length]);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
);
return (
<div style={style} className="h-full">
<ErrorBoundary name="GRPC Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="grpc_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, onClose }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</ErrorBoundary>
</div>
);
}
function GrpcEventRow({
event,
isActive,
onClick,
}: {
event: GrpcEvent;
isActive: boolean;
onClick: () => void;
}) {
const { eventType, status, content, error } = event;
const display = getEventDisplay(eventType, status);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
content={
<span className="text-xs">
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</span>
}
timestamp={event.createdAt}
/>
);
}
function GrpcEventDetail({
event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<Editor
language="json"
defaultValue={event.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}
</div>
)}
<div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? (
<EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(event.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
);
}
function getEventDisplay(
eventType: GrpcEvent['eventType'],
status: GrpcEvent['status'],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
if (eventType === 'server_message') {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
}
if (eventType === 'client_message') {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
}
if (eventType === 'error' || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
}
if (eventType === 'connection_end') {
return { icon: 'check', color: 'success', title: 'Connection response' };
}
return { icon: 'info', color: undefined, title: 'Event' };
}

View File

@@ -19,6 +19,7 @@ type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
@@ -28,20 +29,36 @@ export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = 'Inherited',
onChange,
forceUpdateKey,
}: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders =
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
inheritedHeaders?.filter(
(pair) =>
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
{validInheritedHeaders.length > 0 ? (
<div
className={
hasInheritedHeaders
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
: '@container w-full h-full'
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
summary={
<HStack>
Inherited <CountBadge count={validInheritedHeaders.length} />
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
</HStack>
}
>
@@ -63,8 +80,6 @@ export function HeadersEditor({
))}
</div>
</DetailsBanner>
) : (
<span />
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}

View File

@@ -14,7 +14,7 @@ import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication'
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { Input, type InputProps } from './core/Input';
@@ -62,9 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
<p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p>
<Link href="https://feedback.yaak.app/help/articles/2112119-request-inheritance">
Documentation
</Link>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
</EmptyStateText>
);
}
@@ -140,7 +138,12 @@ export function HttpAuthenticationEditor({ model }: Props) {
}),
)}
>
<IconButton title="Authentication Actions" icon="settings" size="xs" />
<IconButton
title="Authentication Actions"
icon="settings"
size="xs"
className="!text-secondary"
/>
</Dropdown>
)}
</HStack>

View File

@@ -4,7 +4,7 @@ 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, useState } from 'react';
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useAuthTab } from '../hooks/useAuthTab';
@@ -12,7 +12,6 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useImportCurl } from '../hooks/useImportCurl';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
@@ -42,8 +41,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
@@ -70,6 +69,7 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'http_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
@@ -83,19 +83,20 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'httpRequestActiveTabs',
fallback: {},
});
const tabsRef = useRef<TabsRef>(null);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
if (activeRequest == null) {
@@ -260,18 +261,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
@@ -298,7 +287,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
focusParamsTab();
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -309,14 +302,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
}
},
[
activeRequest,
activeRequestId,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
importCurl,
],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
@@ -354,12 +340,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/>
<Tabs
value={activeTab}
ref={tabsRef}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 mb-1.5"
storageKey="http_request_tabs_order"
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -1,15 +1,15 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { lazy, Suspense, useMemo } from 'react';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { useTimelineViewMode } from '../hooks/useTimelineViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util';
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner';
@@ -18,11 +18,13 @@ import { CountBadge } from './core/CountBadge';
import { HotkeyList } from './core/HotkeyList';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { LoadingIcon } from './core/LoadingIcon';
import { Icon, LoadingIcon } from '@yaakapp-internal/ui';
import { PillButton } from './core/PillButton';
import { SizeTag } from './core/SizeTag';
import { HStack, VStack } from './core/Stacks';
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';
@@ -55,32 +57,29 @@ const TAB_HEADERS = 'headers';
const TAB_COOKIES = 'cookies';
const TAB_TIMELINE = 'timeline';
export type TimelineViewMode = 'timeline' | 'text';
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === 'closed' && redirectDropWarning != null;
const cookieCount = useMemo(() => {
if (!responseEvents.data) return 0;
let count = 0;
for (const event of responseEvents.data) {
const e = event.event;
if (
(e.type === 'header_up' && e.name.toLowerCase() === 'cookie') ||
(e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie')
) {
count++;
}
}
return count;
}, [responseEvents.data]);
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const tabs = useMemo<TabItem[]>(
() => [
@@ -92,7 +91,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
onChange: setViewMode,
items: [
{ label: 'Response', value: 'pretty' },
...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
...(mimeType?.startsWith('image')
? []
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
],
},
},
@@ -107,40 +108,47 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
label: 'Headers',
rightSlot: (
<CountBadge
count2={activeResponse?.headers.length ?? 0}
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: 'Cookies',
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
items: [
{ label: 'Timeline', value: 'timeline' },
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
],
},
},
],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
cookieCount,
cookieCounts.sent,
cookieCounts.received,
mimeType,
responseEvents.data?.length,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -167,32 +175,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
>
{activeResponse && (
<HStack
space={2}
alignItems="center"
<div
className={classNames(
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
)}
>
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
<div className="ml-auto">
<HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
{redirectDropWarning.droppedBodyCount === 1
? 'redirect hop'
: 'redirect hops'}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{' '}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(', ')}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm !flex-shrink max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end flex-shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</HStack>
</div>
)}
</HStack>
@@ -204,14 +257,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5"
storageKey="http_response_tabs_order"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
@@ -246,7 +297,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
@@ -271,7 +322,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} />
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
@@ -281,6 +332,54 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
);
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
if (events == null || events.length === 0) return null;
let droppedBodyCount = 0;
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== 'redirect') {
continue;
}
if (event.dropped_body) {
droppedBodyCount += 1;
}
for (const headerName of event.dropped_headers ?? []) {
pushHeaderName(droppedHeaders, headerName);
}
}
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
return null;
}
return {
droppedBodyCount,
droppedHeaders: Array.from(droppedHeaders).sort(),
};
}
function pushHeaderName(headers: Set<string>, headerName: string): void {
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
if (existing == null) {
headers.add(headerName);
}
}
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return 'Dropped body and headers';
}
if (warning.droppedBodyCount > 0) {
return 'Dropped body';
}
return 'Dropped headers';
}
function EnsureCompleteResponse({
response,
Component,

View File

@@ -0,0 +1,418 @@
import type {
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from '@yaakapp-internal/models';
import { type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
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 type { TimelineViewMode } from './HttpResponsePane';
interface Props {
response: HttpResponse;
viewMode: TimelineViewMode;
}
export function HttpResponseTimeline({ response, viewMode }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />;
}
function Inner({ response, viewMode }: Props) {
const [showRaw, setShowRaw] = useState(false);
const { data: events, error, isLoading } = useHttpResponseEvents(response);
// Generate plain text representation of all events (with prefixes for timeline view)
const plainText = useMemo(() => {
if (!events || events.length === 0) return '';
return events.map((event) => formatEventText(event.event, true)).join('\n');
}, [events]);
// Plain text view - show all events as text in an editor
if (viewMode === 'text') {
if (isLoading) {
return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) {
return <div className="p-4 text-danger">{String(error)}</div>;
} else if (!events || events.length === 0) {
return <div className="p-4 text-text-subtlest">No events recorded</div>;
} else {
return (
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
);
}
}
return (
<EventViewer
events={events ?? []}
getEventKey={(event) => event.id}
error={error ? String(error) : null}
isLoading={isLoading}
loadingMessage="Loading events..."
emptyMessage="No events recorded"
splitLayoutName="http_response_events"
defaultRatio={0.25}
renderRow={({ event, isActive, onClick }) => {
const display = getEventDisplay(event.event);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
content={display.summary}
timestamp={event.createdAt}
/>
);
}}
renderDetail={({ event, onClose }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
)}
/>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function EventDetails({
event,
showRaw,
setShowRaw,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => void;
}) {
const { label } = getEventDisplay(event.event);
const e = event.event;
const actions: EventDetailAction[] = [
{
key: 'toggle-raw',
label: showRaw ? 'Formatted' : 'Text',
onClick: () => setShowRaw(!showRaw),
},
];
// Determine the title based on event type
const title = (() => {
switch (e.type) {
case 'header_up':
return 'Header Sent';
case 'header_down':
return 'Header Received';
case 'send_url':
return 'Request';
case 'receive_url':
return 'Response';
case 'redirect':
return 'Redirect';
case 'setting':
return 'Apply Setting';
case 'chunk_sent':
return 'Data Sent';
case 'chunk_received':
return 'Data Received';
case 'dns_resolved':
return e.overridden ? 'DNS Override' : 'DNS Resolution';
default:
return label;
}
})();
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
);
}
// Request URL - show all URL parts separately
if (e.type === 'send_url') {
const auth = e.username || e.password ? `${e.username}:${e.password}@` : '';
const isDefaultPort =
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443);
const portStr = isDefaultPort ? '' : `:${e.port}`;
const query = e.query ? `?${e.query}` : '';
const fragment = e.fragment ? `#${e.fragment}` : '';
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
return (
<KeyValueRows>
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
<KeyValueRow label="Method">{e.method}</KeyValueRow>
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
<KeyValueRow label="Host">{e.host}</KeyValueRow>
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
<KeyValueRow label="Path">{e.path}</KeyValueRow>
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
</KeyValueRows>
);
}
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
);
}
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
const droppedHeaders = e.dropped_headers ?? [];
return (
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
<KeyValueRow label="Headers Dropped">
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
</KeyValueRow>
</KeyValueRows>
);
}
// Settings - show as key/value
if (e.type === 'setting') {
return (
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
}
// DNS Resolution - show hostname, addresses, and timing
if (e.type === 'dns_resolved') {
return (
<KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
<KeyValueRow label="Duration">
{e.overridden ? (
<span className="text-text-subtlest">--</span>
) : (
`${String(e.duration)}ms`
)}
</KeyValueRow>
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
</KeyValueRows>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div>
);
}
type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
/** Get the prefix and text for an event */
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) {
case 'send_url':
return {
prefix: '>',
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
return { prefix: '>', text: `${event.name}: ${event.value}` };
case 'header_down':
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'body dropped' : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
]
.filter(Boolean)
.join(', ');
return {
prefix: '*',
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
};
}
case 'setting':
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
case 'info':
return { prefix: '*', text: event.message };
case 'chunk_sent':
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
case 'chunk_received':
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved':
if (event.overridden) {
return {
prefix: '*',
text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}`,
};
}
return {
prefix: '*',
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`,
};
default:
return { prefix: '*', text: '[unknown event]' };
}
}
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
const { prefix, text } = getEventTextParts(event);
return includePrefix ? `${prefix} ${text}` : text;
}
type EventDisplay = {
icon: IconProps['icon'];
color: IconProps['color'];
label: string;
summary: ReactNode;
};
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case 'setting':
return {
icon: 'settings',
color: 'secondary',
label: 'Setting',
summary: `${event.name} = ${event.value}`,
};
case 'info':
return {
icon: 'info',
color: 'secondary',
label: 'Info',
summary: event.message,
};
case 'redirect': {
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'drop body' : null,
droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
: null,
]
.filter(Boolean)
.join(', ');
return {
icon: 'arrow_big_right_dash',
color: 'success',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
};
}
case 'send_url':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Request',
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Response',
summary: `${event.version} ${event.status}`,
};
case 'header_up':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'header_down':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'chunk_sent':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk sent`,
};
case 'chunk_received':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk received`,
};
case 'dns_resolved':
return {
icon: 'globe',
color: event.overridden ? 'success' : 'secondary',
label: event.overridden ? 'DNS Override' : 'DNS',
summary: event.overridden
? `${event.hostname}${event.addresses.join(', ')} (overridden)`
: `${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`,
};
default:
return {
icon: 'info',
color: 'secondary',
label: 'Unknown',
summary: 'Unknown event',
};
}
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useImportCurl } from '../hooks/useImportCurl';
import { useWindowFocus } from '../hooks/useWindowFocus';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
export function ImportCurlButton() {
const focused = useWindowFocus();

View File

@@ -12,7 +12,7 @@ import { jotaiStore } from '../lib/jotai';
import { CargoFeature } from './CargoFeature';
import type { ButtonProps } from './core/Button';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { PillButton } from './core/PillButton';
const dismissedAtom = atomWithKVStorage<string | null>('dismissed_license_expired', null);

View File

@@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
import { patchModel, workspacesAtom } from '@yaakapp-internal/models';
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';
@@ -12,18 +13,21 @@ import { VStack } from './core/Stacks';
interface Props {
activeWorkspaceId: string;
request: HttpRequest | GrpcRequest | WebsocketRequest;
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
onDone: () => void;
}
export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) {
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
const workspaces = useAtomValue(workspacesAtom);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
return (
<VStack space={4} className="mb-4">
<Select
label="New Workspace"
label="Target Workspace"
name="workspace"
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
@@ -34,27 +38,31 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
/>
<Button
color="primary"
disabled={selectedWorkspaceId === activeWorkspaceId}
disabled={isSameWorkspace}
onClick={async () => {
const patch = {
workspaceId: selectedWorkspaceId,
folderId: null,
};
await patchModel(request, patch);
await Promise.all(requests.map((r) => patchModel(r, patch)));
// Hide after a moment, to give time for request to disappear
// Hide after a moment, to give time for requests to disappear
setTimeout(onDone, 100);
showToast({
id: 'workspace-moved',
message: (
<>
<InlineCode>{resolvedModelName(request)}</InlineCode> moved to{' '}
<InlineCode>
{workspaces.find((w) => w.id === selectedWorkspaceId)?.name ?? 'unknown'}
</InlineCode>
</>
),
message:
requests.length === 1 && requests[0] != null ? (
<>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
</>
) : (
<>
{pluralizeCount('request', requests.length)} moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
</>
),
action: ({ hide }) => (
<Button
size="xs"
@@ -74,7 +82,7 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
});
}}
>
Move
{requests.length === 1 ? 'Move' : `Move ${pluralizeCount('Request', requests.length)}`}
</Button>
</VStack>
);

View File

@@ -4,7 +4,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections';
import { pluralizeCount } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';

View File

@@ -6,7 +6,7 @@ import { useSaveResponse } from '../hooks/useSaveResponse';
import { pluralize } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';

View File

@@ -4,7 +4,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { deleteWebsocketConnections } from '../commands/deleteWebsocketConnections';
import { pluralizeCount } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';

View File

@@ -2,7 +2,7 @@ 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 './core/LoadingIcon';
import { LoadingIcon } from '@yaakapp-internal/ui';
import { EmptyStateText } from './EmptyStateText';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';

View File

@@ -6,7 +6,7 @@ import { showPrompt } from '../lib/prompt';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { HttpMethodTag, HttpMethodTagRaw } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { Icon } from '@yaakapp-internal/ui';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
@@ -71,7 +71,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange={handleChange}
>
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
<HttpMethodTag request={request} />
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
);

View File

@@ -5,15 +5,13 @@ import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge';
import { Icon } from '../core/Icon';
import { HStack } from '../core/Stacks';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { HeaderSize, Icon } from '@yaakapp-internal/ui';
import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsHotkeys } from './SettingsHotkeys';
@@ -40,9 +38,9 @@ const tabs = [
TAB_THEME,
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
@@ -51,7 +49,6 @@ export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
@@ -79,6 +76,10 @@ 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()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<HStack
space={2}
@@ -91,11 +92,10 @@ export default function Settings({ hide }: Props) {
)}
<Tabs
layout="horizontal"
value={tab}
defaultValue={mainTab || tabFromQuery}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map(
(value): TabItem => ({
value,
@@ -123,7 +123,7 @@ export default function Settings({ hide }: Props) {
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.length} />
<CountBadge count={plugins.filter((p) => p.source !== 'bundled').length} />
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
@@ -144,8 +144,8 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />

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