Compare commits

...

262 Commits

Author SHA1 Message Date
Gregory Schier
32b135dbaf Fix sending of ephemeral requests 2023-10-30 08:24:49 -07:00
Gregory Schier
0fc8d12a06 Fix GQL introspection and bearer auth templating 2023-10-30 08:07:34 -07:00
Gregory Schier
3c2bdab101 Fix button styles 2023-10-30 07:27:27 -07:00
Gregory Schier
8b5d7ae3ed Fix editor stale callbacks and recent item deletion 2023-10-30 07:07:14 -07:00
Gregory Schier
51949f4fbf Refactored some core UI 2023-10-30 06:35:52 -07:00
Gregory Schier
6013cd2329 Plugin module loading 2023-10-29 20:50:23 -07:00
Gregory Schier
eba28ade48 Bump version 2023-10-29 17:22:27 -07:00
Gregory Schier
44af1ddc8a Fix sidebar scroll 2023-10-29 17:19:03 -07:00
Gregory Schier
63c0d09df8 A bit more playing with JS runtime 2023-10-29 17:05:48 -07:00
Gregory Schier
f305633d94 Initial "Hello World" for plugins 2023-10-29 16:43:28 -07:00
Gregory Schier
13155f8591 Fix request creation 2023-10-29 12:05:05 -07:00
Gregory Schier
f2ac97aa62 Restore recent environment on workspace change
Fixes #6
2023-10-29 11:32:55 -07:00
Gregory Schier
18eb0027a1 Fix var complete and env dialog actions 2023-10-29 11:18:55 -07:00
Gregory Schier
9e2803fcfb Remove broken key/value enter/backspace logic 2023-10-29 10:45:05 -07:00
Gregory Schier
705e30b6e0 Delete key/value on backspace 2023-10-29 10:26:38 -07:00
Gregory Schier
f1260911ea Move workspace menu, better env mgmt, QoL 2023-10-29 09:45:16 -07:00
Gregory Schier
076ff63dbe Bump version 2023-10-28 23:41:58 -07:00
Gregory Schier
899092b4d2 Better listening for path changes 2023-10-28 23:41:24 -07:00
Gregory Schier
c2c3a28aab Bump version 2023-10-28 22:14:51 -07:00
Gregory Schier
25c0db502e Fixed auto-focus in prompt and env dropdown 2023-10-28 22:14:12 -07:00
Gregory Schier
6dcbe45a53 Clear selected sidebar index on drag-drop end 2023-10-28 21:47:00 -07:00
Gregory Schier
e2b46f25ff Revert debug name 2023-10-28 21:43:09 -07:00
Gregory Schier
981182be46 Fix drag-n-drop things 2023-10-28 21:42:35 -07:00
Gregory Schier
ad164ebd5e Persist window paths 2023-10-28 21:23:46 -07:00
Gregory Schier
cacdad8826 Bump version to 2023.1.0 2023-10-28 19:15:33 -07:00
Gregory Schier
77e5142a7c Update placeholders when env changes 2023-10-28 19:14:51 -07:00
Gregory Schier
613081728d Placeholder error and fix env nav 2023-10-28 19:08:31 -07:00
Gregory Schier
23e77dfec1 Recent requests/workspaces. Closes #1 2023-10-28 18:46:54 -07:00
Gregory Schier
6e273ae2a3 Fix recent requests loading on startup 2023-10-28 18:27:18 -07:00
Gregory Schier
4061094988 Add tauri window save state plugin 2023-10-28 13:14:27 -07:00
Gregory Schier
82b185e27f Fix rustfmt 2023-10-28 12:45:25 -07:00
Gregory Schier
27dc261639 Handle enabled/disabled variables and render multi 2023-10-28 11:36:40 -07:00
Gregory Schier
7e45fecf19 Remove unused Variable type 2023-10-28 11:31:45 -07:00
Gregory Schier
1a5053380b Variables under Environment, and render all props 2023-10-28 11:29:29 -07:00
Gregory Schier
408665c62d Native Codemirror cursor 2023-10-27 13:14:41 -07:00
Gregory Schier
65efee2048 Only wrap URLBar on focus and hotkey to open recent requests 2023-10-27 12:40:43 -07:00
Gregory Schier
3faa66a1fc Resizing window no longer changes sidebar visibility
Fixes #4
2023-10-27 11:21:59 -07:00
Gregory Schier
9dafe4f704 Auto-expand URL bar height 2023-10-27 10:57:07 -07:00
Gregory Schier
356eaf1713 Environment deletion and better actions menu 2023-10-26 16:18:47 -07:00
Gregory Schier
f8584f1537 Stop autocomplete from jumping around 2023-10-26 15:27:48 -07:00
Gregory Schier
6ad6cb34b0 Fix request creation from menu 2023-10-26 10:41:14 -07:00
Gregory Schier
32b27cd780 Send requests with active environment 2023-10-26 10:32:06 -07:00
Gregory Schier
0344a1e8c9 Move create request and fix slow HTML highlighting 2023-10-26 09:42:19 -07:00
Gregory Schier
0515271c12 Better project selector, Fixes #2, and a bunch more 2023-10-26 09:11:44 -07:00
Gregory Schier
5ae8d54ce0 Fixed some routing and introspection requests 2023-10-25 21:53:18 -07:00
Gregory Schier
33c406ce49 Environments in URL and better rendering 2023-10-25 11:13:00 -07:00
Gregory Schier
3b660ddbd0 Move responses dropdown to separate component 2023-10-25 07:59:10 -07:00
Gregory Schier
3132728a27 Fix dialog height 2023-10-25 00:02:51 -07:00
Gregory Schier
7063128342 Better style when no active environment 2023-10-24 23:58:12 -07:00
Gregory Schier
2187775462 Environment dropdown and actions 2023-10-24 09:17:29 -07:00
Gregory Schier
18adcd1004 Started on environment edit dialog 2023-10-23 21:00:36 -07:00
Gregory Schier
b0656d1e38 Hacky implementation of variable autocomplete 2023-10-23 10:31:21 -07:00
Gregory Schier
38e66047e0 Rendered first variable! 2023-10-22 22:30:29 -07:00
Gregory Schier
c24f049dac Updating environments! 2023-10-22 22:06:51 -07:00
Gregory Schier
53d13c8172 Update .gitignore 2023-10-22 20:40:00 -07:00
Gregory Schier
0727c6e437 Prettier and start of env editor 2023-10-22 20:38:57 -07:00
Gregory Schier
8328d20150 Environments data model 2023-10-22 18:28:56 -07:00
Gregory Schier
afe6a3bf57 Environment data model backend 2023-10-22 16:05:09 -07:00
Gregory Schier
d920632cbd Fix some eslint warnings 2023-10-22 11:02:39 -07:00
Gregory Schier
5c456fd4d5 Add APPLE_TEAM_ID 2023-10-18 14:12:08 -07:00
Gregory Schier
38c247e350 Revert artifacts things 2023-10-18 13:25:35 -07:00
Gregory Schier
0c8f72124a Bump cargo deps 2023-10-18 13:25:20 -07:00
Gregory Schier
80ed6b1525 Bump version 2023-10-18 12:14:38 -07:00
Gregory Schier
4424b3f208 Fix sidebar drag-n-drop 2023-10-18 11:58:58 -07:00
Gregory Schier
2c75abce09 Retry button on introspection errors 2023-06-12 13:20:42 -07:00
Gregory Schier
4e15eb197f Fix autocomplete doc font size 2023-05-31 21:32:48 -07:00
Gregory Schier
a7544b4f8c Persist introspection queries and also improve 2023-05-31 21:29:41 -07:00
Gregory Schier
d126aad172 Update tauri NPM 2023-05-29 12:49:50 -07:00
Gregory Schier
acc5c0de50 Fix graphql instrospection 2023-05-29 12:31:34 -07:00
Gregory Schier
3391da111d Change version 2023-04-27 16:53:39 -07:00
Gregory Schier
e37ce96956 Version 1.0.0 2023-04-27 16:47:49 -07:00
Gregory Schier
c51831c975 Fix window methods on Linux/Windows 2023-04-27 16:27:02 -07:00
Gregory Schier
180aa39de4 Cross platform window controls 2023-04-27 10:19:49 -07:00
Gregory Schier
3bd780782e Fix ubuntu build 2023-04-26 17:00:30 -07:00
Gregory Schier
f9ba2f79c2 Windows and Linux 2023-04-26 16:54:51 -07:00
Gregory Schier
d9493de2be Bump version 2023-04-26 16:47:50 -07:00
Gregory Schier
bc9a623742 Very basic CSV viewer 2023-04-22 21:53:04 +08:00
Gregory Schier
532edbf274 Truncate response files 2023-04-14 14:15:33 -07:00
Gregory Schier
1585692328 Randomly offset new windows 2023-04-14 14:05:23 -07:00
Gregory Schier
083f565b12 Fix text encoding and delete responses 2023-04-14 13:50:41 -07:00
Gregory Schier
f7f7438c9e Delete response files 2023-04-14 12:17:11 -07:00
Gregory Schier
19934a93bb Readonly editor disable tabindex 2023-04-13 22:36:11 -07:00
Gregory Schier
577cfe5bdc Fix imageview padding 2023-04-13 22:33:47 -07:00
Gregory Schier
43ac6afae1 Duration and size tags 2023-04-13 20:50:17 -07:00
Gregory Schier
8cc11703d3 Comment 2023-04-13 18:55:32 -07:00
Gregory Schier
4f7a116378 Always store response on filesystem 2023-04-13 18:52:56 -07:00
Gregory Schier
513793d9ce Support binary responses! 2023-04-13 18:48:40 -07:00
Gregory Schier
67f32b6734 Blur backdrop 2023-04-11 16:12:26 -07:00
Gregory Schier
66813d67fe Autofocus buttons 2023-04-11 14:04:23 -07:00
Gregory Schier
a38691ed53 Better opening workspaces and redirect workspace to recent request 2023-04-11 11:11:36 -07:00
Gregory Schier
deeefdcfbf Button disabled style opacity 2023-04-10 16:03:45 -07:00
Gregory Schier
db292511b1 Dropdown keys and pointer events 2023-04-10 16:02:29 -07:00
Gregory Schier
1a5334c1ce Upgrade deno core 2023-04-10 11:16:25 -07:00
Gregory Schier
11002abe39 Tweak response history 2023-04-09 23:15:51 -07:00
Gregory Schier
d922dcb062 Fixed multi-window model sync 2023-04-09 22:32:47 -07:00
Gregory Schier
6fcaa18e86 Tweak recent requests 2023-04-09 22:25:00 -07:00
Gregory Schier
7664c941dd Toggle settings 2023-04-09 22:12:16 -07:00
Gregory Schier
6f5cb528c6 Fix sidebar request focus 2023-04-09 22:03:41 -07:00
Gregory Schier
ebb78922f0 More stuff on sidebar 2023-04-09 21:52:04 -07:00
Gregory Schier
2285fe9f1c Small tweaks 2023-04-09 15:32:13 -07:00
Gregory Schier
38ba8625d8 Request history navigator 2023-04-09 15:26:54 -07:00
Gregory Schier
ab5681c7ad Enter name on create workspace 2023-04-09 12:27:02 -07:00
Gregory Schier
f66dcb9267 Rename workspace 2023-04-09 12:23:41 -07:00
Gregory Schier
1b6cfbac77 Sidebar hover transitions 2023-04-06 16:30:46 -07:00
Gregory Schier
4c27e788ea Remove some more key value usage 2023-04-06 16:26:56 -07:00
Gregory Schier
769da0b052 A bunch of tweaks 2023-04-06 16:05:25 -07:00
Gregory Schier
6b60c86300 macOS 12 2023-04-06 08:39:30 -07:00
Gregory Schier
30c1b5e8c7 Remove system tray icon 2023-04-06 08:15:40 -07:00
Gregory Schier
10af9b6f99 Minor tweaks 2023-04-04 17:21:02 -07:00
Gregory Schier
aa8c066f2d Fix some things 2023-04-04 16:56:45 -07:00
Gregory Schier
b913b74449 Editor line wrapping support (not used yet) 2023-04-04 16:40:37 -07:00
Gregory Schier
b71adce50b remove janky last location tracking 2023-04-04 16:23:08 -07:00
Gregory Schier
0fbb44c701 Fix resize cursor 2023-04-04 16:12:45 -07:00
Gregory Schier
de335e8637 Better button styles 2023-04-04 15:40:25 -07:00
Gregory Schier
2999f63a4c Bump version 2023-04-04 13:56:24 -07:00
Gregory Schier
2abc5e6f0b Some small fixes 2023-04-04 13:56:14 -07:00
Gregory Schier
639de4321e A few fixes 2023-04-04 13:31:48 -07:00
Gregory Schier
b3c461afdd Better status tags and delete request on key 2023-04-04 12:36:30 -07:00
Gregory Schier
7d154800a0 Remove expects from request sending 2023-04-04 08:14:32 -07:00
Gregory Schier
b48ed0399e Fix web view height 2023-04-04 07:51:41 -07:00
Gregory Schier
c5d6e7d74a Fix autocomplete spacing 2023-04-04 07:51:19 -07:00
Gregory Schier
e82f915363 Fix input focus border 2023-04-03 12:19:37 -07:00
Gregory Schier
3128e9ce76 Hot keys and cleanup 2023-04-03 07:59:49 -07:00
Gregory Schier
bc0e86757c Add entitlemet for v8 2023-04-02 20:23:21 -07:00
Gregory Schier
fec99916c2 Debug codesigned build 2023-04-02 19:09:14 -07:00
Gregory Schier
3b5d059b11 Disable code signing 2023-04-02 18:27:14 -07:00
Gregory Schier
c3fe2acc8a Fix tauri script command 2023-04-02 17:25:24 -07:00
Gregory Schier
4d002c412b Fix universal binary 2023-04-02 17:12:20 -07:00
Gregory Schier
46d152b5f1 Bump version 2023-04-02 15:44:41 -07:00
Gregory Schier
25fa81ebbc Fix toolchain 2023-04-02 15:44:21 -07:00
Gregory Schier
7c2de3c360 Add proper target 2023-04-02 15:42:19 -07:00
Gregory Schier
3a3b187cd0 Try universal binary 2023-04-02 15:33:13 -07:00
Gregory Schier
3226bbe083 Fix version 2023-04-02 15:25:24 -07:00
Gregory Schier
a1e4e0e6c9 Bump version 2023-04-02 14:54:41 -07:00
Gregory Schier
b3aa8b893b Notorization (hopefully) 2023-04-02 14:53:49 -07:00
Gregory Schier
f057139634 Change tabs again 2023-04-02 11:11:53 -07:00
Gregory Schier
71a2b11ab4 Better response headers 2023-04-02 10:45:41 -07:00
Gregory Schier
587254a0e7 Show response headers 2023-04-01 23:43:22 -07:00
Gregory Schier
9f4de66f3c Some more refactoring 2023-04-01 21:48:30 -07:00
Gregory Schier
b0d8908724 Refactor debounce and tauri event listeners 2023-04-01 21:39:46 -07:00
Gregory Schier
15c22d98c6 Fix dropdown and dialog key handling 2023-04-01 21:04:39 -07:00
Gregory Schier
3105ae0edc Refactor sidebar display 2023-04-01 20:58:53 -07:00
Gregory Schier
11a89f06c1 Better GraphQL schema fetching 2023-04-01 17:53:36 -07:00
Gregory Schier
9cbe24e740 More eslint fixes 2023-04-01 15:48:37 -07:00
Gregory Schier
bfbed13b8f Add React hooks eslint 2023-04-01 15:26:57 -07:00
Gregory Schier
2268de6321 Fix Tauri listeners 2023-04-01 00:02:17 -07:00
Gregory Schier
dd99aa7fcd Memo editor 2023-03-31 23:19:15 -07:00
Gregory Schier
be436bb706 Fix request duplication 2023-03-31 22:54:32 -07:00
Gregory Schier
bd48726f44 Fix tauri listeners causing too many updates 2023-03-31 22:48:34 -07:00
Gregory Schier
10bea83f98 Remove import 2023-03-31 22:42:41 -07:00
Gregory Schier
8122b4fb84 Fix 2023-03-31 22:42:26 -07:00
Gregory Schier
3ae57fb2d8 Upgrade Deno 2023-03-31 22:42:08 -07:00
Gregory Schier
6dc3eecca4 Tweak 2023-03-31 16:14:25 -07:00
Gregory Schier
9d1d732154 Fix send hotkey 2023-03-31 16:13:34 -07:00
Gregory Schier
8a117415b7 Better schema fetching 2023-03-31 16:02:09 -07:00
Gregory Schier
d36623ebc9 Finally fix the editor! 2023-03-31 15:56:35 -07:00
Gregory Schier
94a3ae3696 Fix editor blurring bug! 2023-03-31 13:53:28 -07:00
Gregory Schier
2836a28988 Better model updates 2023-03-31 13:21:02 -07:00
Gregory Schier
946d7dc89e Fix text obscuring 2023-03-30 17:22:52 -07:00
Gregory Schier
af6300f18b Button ring colors 2023-03-30 17:17:07 -07:00
Gregory Schier
905cb4b18e Remove dummy button 2023-03-30 17:12:38 -07:00
Gregory Schier
305ed09547 Confirm deletions 2023-03-30 17:09:11 -07:00
Gregory Schier
643356bad3 Dedicated event for model creation 2023-03-30 16:49:49 -07:00
Gregory Schier
e458675627 Unify text selection color 2023-03-30 16:36:24 -07:00
Gregory Schier
91e3853692 Some icon tweaks 2023-03-30 16:29:14 -07:00
Gregory Schier
5f0876a136 Fix strict mode editor blur bug 2023-03-30 10:38:33 -07:00
Gregory Schier
3a38127fb4 Better tauri listeners and stuff 2023-03-30 09:05:54 -07:00
Gregory Schier
f3b6070235 Remove updated_by, remember last location 2023-03-30 08:11:51 -07:00
Gregory Schier
5e6e78eb9e Remove hardcoded window config 2023-03-29 22:16:21 -07:00
Gregory Schier
9b66a1d1a8 Fix build 2023-03-29 22:15:55 -07:00
Gregory Schier
e954d0d7bc Remove unused import 2023-03-29 21:53:49 -07:00
Gregory Schier
dab2df7e79 Better multi-window updates 2023-03-29 21:53:20 -07:00
Gregory Schier
bc40e22008 Fixed key/value stuff 2023-03-29 14:46:36 -07:00
Gregory Schier
eef262c398 Fix bundle parts 2023-03-29 14:00:34 -07:00
Gregory Schier
8eab6e14db Fix(ish) multiwindow updates 2023-03-29 11:15:37 -07:00
Gregory Schier
ded33a110a Obscure text 2023-03-29 10:16:51 -07:00
Gregory Schier
e448a7602a Simple auth schemes 2023-03-29 09:03:38 -07:00
Gregory Schier
4c22215ca5 Good start to multi-window 2023-03-28 18:29:40 -07:00
Gregory Schier
4f501abb72 Focus traps for dialog and dropdown 2023-03-26 23:07:09 -07:00
Gregory Schier
b2dcc38982 Confirmation Dialogs 2023-03-26 12:02:20 -07:00
Gregory Schier
11b719955b Floating sidebar 2023-03-26 10:09:28 -07:00
Gregory Schier
d563ac63db Panel icons 2023-03-25 23:29:04 -07:00
Gregory Schier
6d826064c6 Update dialog 2023-03-25 21:59:18 -07:00
Gregory Schier
d30b9d6518 Optimistically-update key values 2023-03-25 21:54:00 -07:00
Gregory Schier
8da3364d0f More tweaks 2023-03-25 21:40:14 -07:00
Gregory Schier
07c372b7f5 Animate dropdown 2023-03-25 21:36:17 -07:00
Gregory Schier
7e01f38253 Animate sidebar transition 2023-03-25 21:31:52 -07:00
Gregory Schier
ba637009a7 Refactor and improve layout resizing 2023-03-25 21:16:10 -07:00
Gregory Schier
da7388e510 Even better layouts 2023-03-25 18:33:01 -07:00
Gregory Schier
3ec88fc896 Better grid layouts 2023-03-25 18:12:09 -07:00
Gregory Schier
1c9381b2bd Global layout component 2023-03-25 13:26:31 -07:00
Gregory Schier
06349b8d5b Better dropdown separator 2023-03-25 11:06:05 -07:00
Gregory Schier
6dc7dc6ad2 Fix sidebar drag 2023-03-24 08:37:52 -07:00
Gregory Schier
f981a15ec3 Upgrade TYpescript 2023-03-23 15:37:36 -07:00
Gregory Schier
8b648c0301 Fix resize 2023-03-23 07:47:58 -07:00
Gregory Schier
83ce09075b Style tweak 2023-03-21 23:59:09 -07:00
Gregory Schier
168dfb9f6b GraphQL autocomplete and duplicate request 2023-03-21 23:54:45 -07:00
Gregory Schier
9b8961c23d Tweak sidebar drag resizer 2023-03-21 19:36:32 -07:00
Gregory Schier
89bca42ee6 Minor style tweaks 2023-03-21 18:31:05 -07:00
Gregory Schier
07d2a43a17 Pull out resize bar 2023-03-21 16:53:49 -07:00
Gregory Schier
c84f2afd09 Resize titlebar and tweak things 2023-03-21 16:42:52 -07:00
Gregory Schier
df4dbaecc8 Remove icon generation from build script 2023-03-21 14:21:07 -07:00
Gregory Schier
d9bf03cefe query client cache and better body types 2023-03-21 11:38:37 -07:00
Gregory Schier
39223e8d89 Fix workspace deletion 2023-03-21 09:32:15 -07:00
Gregory Schier
67925e18b2 Use proper gray for syntax 2023-03-20 17:15:12 -07:00
Gregory Schier
89ad65513d fix import 2023-03-20 17:13:14 -07:00
Gregory Schier
90166ddfa3 Minor tweaks 2023-03-20 17:12:19 -07:00
Gregory Schier
0981b23faf Fix URL bar spacing 2023-03-20 17:01:29 -07:00
Gregory Schier
664f3b4d87 Better radio dropdown type 2023-03-20 16:54:26 -07:00
Gregory Schier
dc97b91a4e Typesafe routing and CM line height issue 2023-03-20 16:47:36 -07:00
Gregory Schier
d310272d19 Better tab dropdown handling 2023-03-20 14:14:30 -07:00
Gregory Schier
f1be3f01e1 Fix request creation priority 2023-03-20 13:56:03 -07:00
Gregory Schier
c57b6e1d73 Remove log 2023-03-20 13:49:35 -07:00
Gregory Schier
a938dc45f0 Handle "no body" case 2023-03-20 13:49:21 -07:00
Gregory Schier
bb139744a1 Small fix 2023-03-20 13:37:14 -07:00
Gregory Schier
3aa3e09552 Fix pointer window drag 2023-03-20 13:34:49 -07:00
Gregory Schier
74abfd21b8 Fix extra dropdown element 2023-03-20 13:19:23 -07:00
Gregory Schier
e703817ba2 Remove most of Radix UI 2023-03-20 13:16:58 -07:00
Gregory Schier
80dd1e457b Better Header validation 2023-03-20 01:38:05 -07:00
Gregory Schier
ea9f8d3ab2 Tweak sidebar 2023-03-20 01:30:45 -07:00
Gregory Schier
fa222bdf12 Fix pair editor container 2023-03-20 01:18:44 -07:00
Gregory Schier
45b360dabd Fix input thingy 2023-03-20 01:14:13 -07:00
Gregory Schier
5923399359 Container queries! 2023-03-20 01:08:41 -07:00
Gregory Schier
f4600f3e90 Better pair editor delete button 2023-03-20 00:30:42 -07:00
Gregory Schier
f883837685 Pair validation 2023-03-20 00:17:29 -07:00
Gregory Schier
b58bc409f0 Don't send disabled headers 2023-03-20 00:05:19 -07:00
Gregory Schier
e893e539bb Small tweak 2023-03-20 00:04:40 -07:00
Gregory Schier
90294fbb5d Pair checkboxes and fix twig indent 2023-03-20 00:03:33 -07:00
Gregory Schier
ae65f222bc Rewrote twig grammar 2023-03-19 22:12:11 -07:00
Gregory Schier
1b9813fb4c Re-order of pair editor 2023-03-19 13:28:57 -07:00
Gregory Schier
b708b5ae41 Better header editor and added completion data 2023-03-19 11:09:21 -07:00
Gregory Schier
df136fa915 A couple tweaks 2023-03-19 01:01:13 -07:00
Gregory Schier
f8329f5b8d Persist sort priority! 2023-03-19 00:48:09 -07:00
Gregory Schier
21141090de Create new workspace, and more optimizations 2023-03-18 19:36:31 -07:00
Gregory Schier
c0d9740a7d Optimized a few components 2023-03-18 18:49:01 -07:00
Gregory Schier
afcf630443 Fix sidebar drag-n-drop 2023-03-18 18:09:36 -07:00
Gregory Schier
1fe2c9826a Got drag opacity working 2023-03-18 15:06:38 -07:00
Gregory Schier
7272b80a3f Good start to drag-n-drop sidebar! 2023-03-18 14:41:07 -07:00
Gregory Schier
92114b7368 Fix mixed parser 2023-03-17 17:57:43 -07:00
Gregory Schier
f39d3e7eed Dropdown highlight 2023-03-17 17:32:24 -07:00
Gregory Schier
cbe0d27a5e Beginnings of autocomplete for headers 2023-03-17 16:51:20 -07:00
Gregory Schier
cd39699467 Flatten migrations, kvs lib, fix tabs 2023-03-17 08:36:21 -07:00
Gregory Schier
b3ea67aacf Sidebar item dropdown 2023-03-16 15:37:53 -07:00
Gregory Schier
db4ed9797c Sidebar dragging 2023-03-16 14:34:49 -07:00
Gregory Schier
1ea7d7d685 Add devtools toggle hotkey 2023-03-16 11:25:38 -07:00
Gregory Schier
2df725b57a Adjust window sizes 2023-03-16 11:16:23 -07:00
Gregory Schier
74e6648249 Store appearance in k/v 2023-03-16 11:01:30 -07:00
Gregory Schier
1026350d9c Hotkeys and view mode kvs 2023-03-16 09:24:28 -07:00
Gregory Schier
98fb87874d Some fixes 2023-03-15 23:33:46 -07:00
Gregory Schier
41fc3afdc1 Got key values working 2023-03-15 23:24:41 -07:00
Gregory Schier
83dbf46ba4 Fix editor padding 2023-03-15 17:29:35 -07:00
Gregory Schier
0b2e35bdde Minor style updates 2023-03-15 17:25:04 -07:00
Gregory Schier
d90a7331c9 Add stuff to app header 2023-03-15 16:35:19 -07:00
Gregory Schier
264e64a996 Better request delete and formatting 2023-03-15 09:41:38 -07:00
Gregory Schier
8915915c47 Fix graphql and other things 2023-03-15 09:06:56 -07:00
Gregory Schier
951ed787fa Header editor to pair editor 2023-03-15 08:09:45 -07:00
Gregory Schier
64ef6b0c22 Better header editor 2023-03-15 07:54:04 -07:00
Gregory Schier
ef18377b3c Strict mode and tweak layout padding 2023-03-14 20:19:45 -07:00
Gregory Schier
5904b6fded Add GraphQL variables editor 2023-03-14 19:56:02 -07:00
Gregory Schier
f4401e77bb GraphQL query editor transformer works! 2023-03-14 19:08:18 -07:00
Gregory Schier
efa5455a7b Add body type to request and tab dropdown 2023-03-14 11:18:56 -07:00
Gregory Schier
619c8d9e72 Improved header editor 2023-03-14 00:54:41 -07:00
218 changed files with 14309 additions and 12589 deletions

View File

@@ -1,31 +1,37 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
ignorePatterns: ['src-tauri/**/*'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports",
}]
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:import/recommended",
"plugin:jsx-a11y/recommended",
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier"
],
parser: "@typescript-eslint/parser",
parserOptions: {
project: ["./tsconfig.json"]
},
ignorePatterns: ["src-tauri/**/*"],
settings: {
react: {
version: "detect"
},
"import/resolver": {
node: {
paths: ["src-web"],
extensions: [".ts", ".tsx"]
}
}
},
rules: {
"jsx-a11y/no-autofocus": "off",
"react/react-in-jsx-scope": "off",
"import/no-unresolved": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports"
}]
}
};

View File

@@ -5,16 +5,26 @@ on:
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest, macos-latest, windows-latest ]
include:
- os: macos-12
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-2022
target: x86_64-pc-windows-msvc
- os: ubuntu-20.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust
uses: actions/cache@v2
with:
@@ -28,7 +38,7 @@ jobs:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
if: matrix.os == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
@@ -36,14 +46,24 @@ jobs:
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
# Pin dev version to get non-default targets
# https://github.com/tauri-apps/tauri-action/issues/356
- uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '<!-- Release Notes -->'
releaseDraft: true
prerelease: false
args: '--target ${{ matrix.target }}'

1
.gitignore vendored
View File

@@ -22,5 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.eslintcache
*.sqlite

1
.husky/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

@@ -6,9 +6,7 @@
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs>
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
</envs>
<envs />
<method v="2" />
</configuration>
</component>
</component>

5
.sqllsrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "yaak-dev",
"adapter": "sqlite3",
"filename": "src-tauri/db.sqlite"
}

7
Makefile Normal file
View File

@@ -0,0 +1,7 @@
.PHONY: sqlx-prepare, dev
sqlx-prepare:
cd src-tauri && cargo sqlx prepare --database-url 'sqlite://db.sqlite'
dev:
npm run tauri-dev

View File

@@ -1,3 +1,16 @@
# Tauri REST Client
# Yaak Network Toolkit
It's a REST client, yo.
The most fun you'll ever have working with APIs.
## Common Commands
```sh
# Start dev app
npm run tauri-dev
# Migration commands
cd src-tauri
cargo sqlx migrate add ${MIGRATION_NAME}
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
```

BIN
design/Icons.afdesign Normal file
View File

Binary file not shown.

View File

@@ -1,17 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
</head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
<style>
body {
background-color: white;
}
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal"></div>
<div id="react-portal"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
</html>

11615
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,23 @@
{
"name": "tauri-app",
"name": "yaak-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"tauri-dev": "tauri dev",
"tauri-build": "npm run build:icon && tauri build",
"tauri-dev": "YAAK_ENV=development tauri dev",
"tauri-build": "tauri build",
"tauri": "tauri",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
"build:icon": "tauri icon src-tauri/icons/icon.png",
"build:frontend": "vite build",
"test": "vitest",
"coverage": "vitest run --coverage"
"coverage": "vitest run --coverage",
"prepare": "husky install"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
@@ -25,51 +26,66 @@
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"@react-hook/resize-observer": "^1.2.6",
"@tailwindcss/container-queries": "^0.1.0",
"@tanstack/query-sync-storage-persister": "^4.27.1",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.28.0",
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.5.1",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"cm6-graphql": "^0.0.9",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@tauri-apps/cli": "^1.5.4",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@types/react": "^18.0.31",
"@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3",
"lint-staged": "^15.0.2",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"typescript": "^5.0.2",
"vite": "^4.0.0",
"vite-plugin-svgr": "^2.4.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --cache --fix",
"*.{js,css,md}": "prettier --write"
}
}

1
rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
edition = "2018"

3400
src-tauri/Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,29 +7,32 @@ license = "MIT"
repository = "https://github.com/gschier/yaak-app"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
strip = true # Automatically strip symbols from the binary.
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[target.'cfg(target_os = "macos")'.dependencies]
objc = { version = "0.2.7" }
cocoa = { version = "0.24.1" }
objc = "0.2.7"
cocoa = "0.25.0"
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = { version = "0.2.8" }
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["sync"] }
futures = { version = "0.3.26" }
deno_core = { version = "0.174.0" }
deno_ast = { version = "0.24.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = { version = "1.3.0" }
rand = { version = "0.8.5" }
base64 = "0.21.0"
boa_engine = "0.17.3"
boa_runtime = "0.17.3"
chrono = { version = "0.4.23", features = ["serde"] }
futures = "0.3.26"
http = "0.2.8"
rand = "0.8.5"
reqwest = { version = "0.11.14", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tokio = { version = "1.25.0", features = ["sync"] }
uuid = "1.3.0"
[features]
# by default Tauri runs in production mode

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Binary file not shown.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

View File

@@ -1,39 +1,65 @@
CREATE TABLE key_values
(
model TEXT DEFAULT 'key_value' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
);
CREATE TABLE workspaces
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'workspace' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
name TEXT NOT NULL,
description TEXT NOT NULL
name TEXT NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE http_requests
(
id TEXT NOT NULL PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'http_request' NOT NULL,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT,
body_type TEXT
);
CREATE TABLE http_responses
(
id TEXT NOT NULL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES http_requests (id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'http_response' NOT NULL,
request_id TEXT NOT NULL
REFERENCES http_requests
ON DELETE CASCADE,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
elapsed INTEGER NOT NULL,
status INTEGER NOT NULL,
elapsed INTEGER NOT NULL,
status INTEGER NOT NULL,
status_reason TEXT,
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL,
error TEXT
);

View File

@@ -1 +0,0 @@
ALTER TABLE http_responses ADD COLUMN error TEXT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;

View File

@@ -0,0 +1,5 @@
DELETE FROM main.http_responses;
ALTER TABLE http_responses DROP COLUMN body;
ALTER TABLE http_responses ADD COLUMN body BLOB;
ALTER TABLE http_responses ADD COLUMN body_path TEXT;
ALTER TABLE http_responses ADD COLUMN content_length INTEGER;

View File

@@ -0,0 +1,15 @@
CREATE TABLE environments
(
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'workspace' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
name TEXT NOT NULL,
data TEXT NOT NULL
DEFAULT '{}'
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments DROP COLUMN data;
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1,3 @@
export function hello() {
sayHello('Plugin');
}

View File

@@ -0,0 +1,7 @@
import { hello } from './hello.js';
export function entrypoint() {
hello();
console.log('Try JSON parse', JSON.parse(`{ "hello": 123 }`).hello);
console.log('Try RegExp', '123'.match(/[\d]+/));
}

View File

@@ -1 +0,0 @@
Deno.core.opAsync('op_hello', 'Deno');

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,38 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
use std::collections::HashMap;
use std::fs::create_dir_all;
use http::header::{ACCEPT, HeaderName, USER_AGENT};
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use rand::random;
use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
use tauri::{AppHandle, Menu, State, Submenu, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File};
use std::io::Write;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{CustomMenuItem, Manager, WindowEvent};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use window_ext::WindowExt;
use window_ext::TrafficLightWindowExt;
mod models;
mod runtime;
mod render;
mod window_ext;
mod plugin;
#[derive(serde::Serialize)]
pub struct CustomResponse {
@@ -49,49 +55,39 @@ async fn migrate_db(
.path_resolver()
.resolve_resource("migrations")
.expect("failed to resolve resource");
println!("Running migrations at {}", p.to_string_lossy());
let m = Migrator::new(p).await.expect("Failed to load migrations");
m.run(pool).await.expect("Failed to run migrations");
println!("Migrations ran");
println!("Migrations complete");
Ok(())
}
#[tauri::command]
async fn send_request(
async fn send_ephemeral_request(
request: models::HttpRequest,
environment_id: Option<&str>,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
return actually_send_ephemeral_request(request, &response, &environment_id2, &app_handle, pool)
.await;
}
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
async fn actually_send_ephemeral_request(
request: models::HttpRequest,
response: &models::HttpResponse,
environment_id: &str,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let environment = models::get_environment(environment_id, pool).await.ok();
let environment_ref = environment.as_ref();
let mut url_string = req.url.to_string();
let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
variables.insert("TOKEN", "s3cret");
variables.insert("DOMAIN", "schier.co");
variables.insert("BASE_URL", "https://schier.co");
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
url_string = re
.replace(&url_string, |caps: &tauri::regex::Captures| {
let key = caps.get(1).unwrap().as_str();
match variables.get(key) {
Some(v) => v,
None => "",
}
})
.to_string();
let mut url_string = render::render(&request.url, environment.as_ref());
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
@@ -99,6 +95,7 @@ async fn send_request(
let client = reqwest::Client::builder()
.redirect(Policy::none())
// .danger_accept_invalid_certs(true)
.build()
.expect("Failed to build client");
@@ -106,54 +103,94 @@ async fn send_request(
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in req.headers.0 {
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
if !h.enabled {
continue;
}
let name = render::render(&h.name, environment_ref);
let value = render::render(&h.value, environment_ref);
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header name: {}", e);
continue;
}
};
let header_value = match HeaderValue::from_str(h.value.as_str()) {
let header_value = match HeaderValue::from_str(value.as_str()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
let m =
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method");
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
let a = request.authentication.0;
if b == "basic" {
let raw_username = a
.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let raw_password = a
.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let username = render::render(raw_username, environment_ref);
let password = render::render(raw_password, environment_ref);
let auth = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
);
} else if b == "bearer" {
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
let token = render::render(raw_token, environment_ref);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
}
}
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match req.body {
Some(b) => builder.body(b).build(),
None => builder.build(),
let sendable_req_result = match (request.body, request.body_type) {
(Some(raw_body), Some(_)) => {
let body = render::render(&raw_body, environment_ref);
builder.body(body).build()
}
_ => builder.build(),
};
let sendable_req = match sendable_req_result {
Ok(r) => r,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
return response_err(response, e.to_string(), &app_handle, pool).await;
}
};
let resp = client.execute(sendable_req).await;
let raw_response = client.execute(sendable_req).await;
let p = app_handle
.path_resolver()
.resolve_resource("plugins/plugin.ts")
.expect("failed to resolve resource");
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
match resp {
match raw_response {
Ok(v) => {
let mut response = response.clone();
response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json(
@@ -166,59 +203,241 @@ async fn send_request(
.collect(),
);
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
response.content_length = Some(body_bytes.len() as i64);
{
// Write body to FS
let dir = app_handle.path_resolver().app_data_dir().unwrap();
let base_dir = dir.join("responses");
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
let body_path = match response.id == "" {
false => base_dir.join(response.id.clone()),
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
};
let mut f = File::options()
.create(true)
.truncate(true)
.write(true)
.open(&body_path)
.expect("Failed to open file");
f.write_all(body_bytes.as_slice())
.expect("Failed to write to file");
response.body_path = Some(
body_path
.to_str()
.expect("Failed to get body path")
.to_string(),
);
}
// Also store body directly on the model, if small enough
if body_bytes.len() < 100_000 {
response.body = Some(body_bytes);
}
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response(response, pool)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
if request.id != "" {
emit_side_effect(app_handle, "updated_model", &response);
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
#[tauri::command]
async fn send_request(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
environment_id: Option<&str>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool)
.await
.expect("Failed to create response");
let response2 = response.clone();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
tokio::spawn(async move {
actually_send_ephemeral_request(req, &response2, &environment_id2, &app_handle2, &pool2)
.await
.expect("Failed to send request");
});
emit_and_return(&window, "created_model", response)
}
async fn response_err(
mut response: models::HttpResponse,
response: &models::HttpResponse,
error: String,
app_handle: AppHandle<Wry>,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.error = Some(error.clone());
response = models::update_response(response, pool)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
emit_side_effect(app_handle, "updated_model", &response);
Ok(response)
}
#[tauri::command]
async fn get_key_value(
namespace: &str,
key: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Option<models::KeyValue>, ()> {
let pool = &*db_instance.lock().await;
let result = models::get_key_value(namespace, key, pool).await;
Ok(result)
}
#[tauri::command]
async fn set_key_value(
namespace: &str,
key: &str,
value: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::KeyValue, String> {
let pool = &*db_instance.lock().await;
let (key_value, created) = models::set_key_value(namespace, key, value, pool).await;
if created {
emit_and_return(&window, "created_model", key_value)
} else {
emit_and_return(&window, "updated_model", key_value)
}
}
#[tauri::command]
async fn create_workspace(
name: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let created_workspace = models::create_workspace(name, "", pool)
.await
.expect("Failed to create workspace");
emit_and_return(&window, "created_model", created_workspace)
}
#[tauri::command]
async fn create_environment(
workspace_id: &str,
name: &str,
variables: Vec<models::EnvironmentVariable>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let created_environment = models::create_environment(workspace_id, name, variables, pool)
.await
.expect("Failed to create environment");
emit_and_return(&window, "created_model", created_environment)
}
#[tauri::command]
async fn create_request(
workspace_id: &str,
name: &str,
app_handle: AppHandle<Wry>,
sort_priority: f64,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> {
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let headers = Vec::new();
let created_request =
models::upsert_request(None, workspace_id, name, "GET", None, "", headers, pool)
.await
.expect("Failed to create request");
let created_request = models::upsert_request(
None,
workspace_id,
name,
"GET",
None,
None,
HashMap::new(),
None,
"",
headers,
sort_priority,
pool,
)
.await
.expect("Failed to create request");
app_handle
.emit_all("updated_request", &created_request)
.unwrap();
emit_and_return(&window, "created_model", created_request)
}
Ok(created_request.id)
#[tauri::command]
async fn duplicate_request(
id: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let request = models::duplicate_request(id, pool)
.await
.expect("Failed to duplicate request");
emit_and_return(&window, "updated_model", request)
}
#[tauri::command]
async fn update_workspace(
workspace: models::Workspace,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_workspace)
}
#[tauri::command]
async fn update_environment(
environment: models::Environment,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let updated_environment = models::update_environment(
environment.id.as_str(),
environment.name.as_str(),
environment.variables.0,
pool,
)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_environment)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
@@ -231,29 +450,30 @@ async fn update_request(
None => None,
};
// TODO: Figure out how to make this better
let updated_request = models::upsert_request(
Some(request.id.as_str()),
request.workspace_id.as_str(),
request.name.as_str(),
request.method.as_str(),
body,
request.body_type,
request.authentication.0,
request.authentication_type,
request.url.as_str(),
request.headers.0,
request.sort_priority,
pool,
)
.await
.expect("Failed to update request");
.await
.expect("Failed to update request");
app_handle
.emit_all("updated_request", updated_request)
.unwrap();
Ok(())
emit_and_return(&window, "updated_model", updated_request)
}
#[tauri::command]
async fn delete_request(
app_handle: AppHandle<Wry>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpRequest, String> {
@@ -261,13 +481,24 @@ async fn delete_request(
let req = models::delete_request(request_id, pool)
.await
.expect("Failed to delete request");
app_handle.emit_all("deleted_request", request_id).unwrap();
Ok(req)
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn requests(
async fn delete_environment(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
environment_id: &str,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_environment(environment_id, pool)
.await
.expect("Failed to delete environment");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn list_requests(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpRequest>, String> {
@@ -278,7 +509,53 @@ async fn requests(
}
#[tauri::command]
async fn responses(
async fn list_environments(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Environment>, String> {
let pool = &*db_instance.lock().await;
let environments = models::find_environments(workspace_id, pool)
.await
.expect("Failed to find environments");
Ok(environments)
}
#[tauri::command]
async fn get_request(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
models::get_request(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_environment(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
models::get_environment(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_workspace(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
models::get_workspace(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
@@ -291,12 +568,14 @@ async fn responses(
#[tauri::command]
async fn delete_response(
id: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
models::delete_response(id, pool)
let response = models::delete_response(id, pool)
.await
.map_err(|e| e.to_string())
.expect("Failed to delete response");
emit_and_return(&window, "deleted_model", response)
}
#[tauri::command]
@@ -311,7 +590,7 @@ async fn delete_all_responses(
}
#[tauri::command]
async fn workspaces(
async fn list_workspaces(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Workspace>, String> {
let pool = &*db_instance.lock().await;
@@ -319,9 +598,10 @@ async fn workspaces(
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
let workspace = models::create_workspace("Default", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
let workspace =
models::create_workspace("My Project", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
Ok(vec![workspace])
} else {
Ok(workspaces)
@@ -329,105 +609,248 @@ async fn workspaces(
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
create_window(&window.app_handle(), Some(url));
Ok(())
}
#[tauri::command]
async fn delete_workspace(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
workspace_id: &str,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(workspace_id, pool)
.await
.expect("Failed to delete workspace");
emit_and_return(&window, "deleted_model", workspace)
}
fn main() {
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let tray_menu = SystemTrayMenu::new().add_item(quit);
let system_tray = SystemTray::new().with_menu(tray_menu);
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let submenu = Submenu::new("Test Menu", Menu::new()
.add_item(CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size").accelerator("CmdOrCtrl+0"))
.add_item(CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"))
.add_item(CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-")),
);
let menu = default_menu.add_submenu(submenu);
tauri::Builder::default()
.menu(menu)
.system_tray(system_tray)
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
let win = app.get_window("main").unwrap();
let dir = match is_dev() {
true => current_dir().unwrap(),
false => app.path_resolver().app_data_dir().unwrap(),
};
#[cfg(target_os = "macos")]
win.position_traffic_lights();
Ok(())
})
.setup(|app| {
let dir = app.path_resolver().app_data_dir().unwrap();
create_dir_all(dir.clone()).expect("Problem creating App directory!");
let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string);
println!("Connecting to database at {}", url);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
.await
.expect("Failed to connect to database");
// Setup the DB handle
let m = Mutex::new(pool);
migrate_db(app.handle(), &m)
.await
.expect("Failed to migrate database");
app.manage(m);
Ok(())
})
})
.on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
};
}
})
.on_menu_event(|event| {
match event.menu_item_id() {
"quit" => std::process::exit(0),
"close" => event.window().close().unwrap(),
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
_ => {}
};
})
.on_window_event(|e| {
let apply_offset = || {
let win = e.window();
#[cfg(target_os = "macos")]
win.position_traffic_lights();
};
match e.event() {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
_ => {}
}
})
.invoke_handler(tauri::generate_handler![
greet,
workspaces,
requests,
send_request,
create_environment,
create_request,
update_request,
delete_request,
responses,
delete_response,
create_workspace,
delete_all_responses,
delete_environment,
delete_request,
delete_response,
delete_workspace,
duplicate_request,
get_key_value,
get_environment,
get_request,
get_workspace,
list_environments,
list_requests,
list_responses,
list_workspaces,
new_window,
send_ephemeral_request,
send_request,
set_key_value,
update_environment,
update_request,
update_workspace,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| match event {
RunEvent::Ready => {
let w = create_window(app_handle, None);
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
plugin::test_plugins(&app_handle);
}
// ExitRequested { api, .. } => {
// }
_ => {}
});
}
fn is_dev() -> bool {
let env = option_env!("YAAK_ENV");
env.unwrap_or("production") != "production"
}
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
if is_dev() {
test_menu = test_menu
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
);
}
let submenu = Submenu::new("Test Menu", test_menu);
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let menu = default_menu.add_submenu(submenu);
let mut win_builder = tauri::WindowBuilder::new(
handle,
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.position(
// Randomly offset so windows don't stack exactly
100.0 + random::<f64>() * 30.0,
100.0 + random::<f64>() * 30.0,
)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
});
// Add macOS-only things
#[cfg(target_os = "macos")]
{
win_builder = win_builder
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay);
}
let win = win_builder.build().expect("failed to build window");
let win2 = win.clone();
let handle2 = handle.clone();
win.on_menu_event(move |event| match event.menu_item_id() {
"quit" => std::process::exit(0),
"close" => win2.close().unwrap(),
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
"zoom_in" => win2.emit("zoom", 1).unwrap(),
"zoom_out" => win2.emit("zoom", -1).unwrap(),
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
"focus_url" => win2.emit("focus_url", true).unwrap(),
"focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(),
"send_request" => win2.emit("send_request", true).unwrap(),
"new_request" => _ = win2.emit("new_request", true).unwrap(),
"toggle_settings" => _ = win2.emit("toggle_settings", true).unwrap(),
"duplicate_request" => _ = win2.emit("duplicate_request", true).unwrap(),
"refresh" => win2.eval("location.reload()").unwrap(),
"new_window" => _ = create_window(&handle2, None),
"toggle_devtools" => {
if win2.is_devtools_open() {
win2.close_devtools();
} else {
win2.open_devtools();
}
}
_ => {}
});
let win3 = win.clone();
win.on_window_event(move |e| {
let apply_offset = || {
win3.position_traffic_lights();
};
match e {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
WindowEvent::CloseRequested { .. } => {
println!("CLOSE REQUESTED");
// api.prevent_close();
}
_ => {}
}
});
win.position_traffic_lights();
win
}
/// Emit an event to all windows, with a source window
fn emit_and_return<S: Serialize + Clone, E>(
current_window: &Window<Wry>,
event: &str,
payload: S,
) -> Result<S, E> {
current_window.emit_all(event, &payload).unwrap();
Ok(payload)
}
/// Emit an event to all windows, used for side-effects where there is no source window to attribute. This
fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &str, payload: S) {
app_handle.emit_all(event, &payload).unwrap();
}

View File

@@ -1,23 +1,49 @@
use std::collections::HashMap;
use std::fs;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::Json;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub name: String,
pub description: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Environment {
pub id: String,
pub workspace_id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub name: String,
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentVariable {
#[serde(default)]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
pub enabled: bool,
pub name: String,
pub value: String,
}
@@ -26,14 +52,18 @@ pub struct HttpRequestHeader {
#[serde(rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub workspace_id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub workspace_id: String,
pub sort_priority: f64,
pub name: String,
pub url: String,
pub method: String,
pub body: Option<String>,
pub body_type: Option<String>,
pub authentication: Json<HashMap<String, JsonValue>>,
pub authentication_type: Option<String>,
pub headers: Json<Vec<HttpRequestHeader>>,
}
@@ -44,29 +74,86 @@ pub struct HttpResponseHeader {
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
pub model: String,
pub workspace_id: String,
pub request_id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub error: Option<String>,
pub url: String,
pub content_length: Option<i64>,
pub elapsed: i64,
pub status: i64,
pub status_reason: Option<String>,
pub body: String,
pub body: Option<Vec<u8>>,
pub body_path: Option<String>,
pub headers: Json<Vec<HttpResponseHeader>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeyValue {
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub namespace: String,
pub key: String,
pub value: String,
}
pub async fn set_key_value(
namespace: &str,
key: &str,
value: &str,
pool: &Pool<Sqlite>,
) -> (KeyValue, bool) {
let existing = get_key_value(namespace, key, pool).await;
sqlx::query!(
r#"
INSERT INTO key_values (namespace, key, value)
VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
value = excluded.value
"#,
namespace,
key,
value,
)
.execute(pool)
.await
.expect("Failed to insert key value");
let kv = get_key_value(namespace, key, pool)
.await
.expect("Failed to get key value");
return (kv, existing.is_none());
}
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
sqlx::query_as!(
KeyValue,
r#"
SELECT model, created_at, updated_at, namespace, key, value
FROM key_values
WHERE namespace = ? AND key = ?
"#,
namespace,
key,
)
.fetch_one(pool)
.await
.ok()
}
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
SELECT id, model, created_at, updated_at, name, description
FROM workspaces
"#,
)
@@ -78,9 +165,8 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
FROM workspaces
WHERE id = ?
SELECT id, model, created_at, updated_at, name, description
FROM workspaces WHERE id = ?
"#,
id,
)
@@ -88,12 +174,31 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
.await
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM workspaces
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
for r in find_responses_by_workspace_id(id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(workspace)
}
pub async fn create_workspace(
name: &str,
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id("wk");
let id = generate_id(Some("wk"));
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
@@ -104,54 +209,208 @@ pub async fn create_workspace(
description,
)
.execute(pool)
.await
.expect("Failed to insert new workspace");
.await?;
get_workspace(&id, pool).await
}
pub async fn find_environments(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<Environment>, sqlx::Error> {
sqlx::query_as!(
Environment,
r#"
SELECT id, workspace_id, model, created_at, updated_at, name,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM environments
WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn create_environment(
workspace_id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pool: &Pool<Sqlite>,
) -> Result<Environment, sqlx::Error> {
let id = generate_id(Some("en"));
let trimmed_name = name.trim();
let variables_json = Json(variables);
sqlx::query!(
r#"
INSERT INTO environments (id, workspace_id, name, variables)
VALUES (?, ?, ?, ?)
"#,
id,
workspace_id,
trimmed_name,
variables_json,
)
.execute(pool)
.await?;
get_environment(&id, pool).await
}
pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
let env = get_environment(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM environments
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(env)
}
pub async fn update_environment(
id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pool: &Pool<Sqlite>,
) -> Result<Environment, sqlx::Error> {
let variables_json = Json(variables);
sqlx::query!(
r#"
UPDATE environments
SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)
WHERE id = ?;
"#,
name,
variables_json,
id,
)
.execute(pool)
.await?;
get_environment(id, pool).await
}
pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
sqlx::query_as!(
Environment,
r#"
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
name,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM environments
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool).await?;
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.authentication.0,
existing.authentication_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority + 0.001,
pool,
)
.await
}
pub async fn upsert_request(
id: Option<&str>,
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
body_type: Option<String>,
authentication: HashMap<String, JsonValue>,
authentication_type: Option<String>,
url: &str,
headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let generated_id;
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id("rq");
generated_id = generate_id(Some("rq"));
generated_id.as_str()
}
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
sqlx::query!(
r#"
INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO http_requests (
id,
workspace_id,
name,
url,
method,
body,
body_type,
authentication,
authentication_type,
headers,
sort_priority
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
method = excluded.method,
headers = excluded.headers,
body = excluded.body,
url = excluded.url
body_type = excluded.body_type,
authentication = excluded.authentication,
authentication_type = excluded.authentication_type,
url = excluded.url,
sort_priority = excluded.sort_priority
"#,
id,
workspace_id,
name,
trimmed_name,
url,
method,
body,
body_type,
auth_json,
authentication_type,
headers_json,
sort_priority,
)
.execute(pool)
.await
.expect("Failed to insert new request");
.await?;
get_request(id, pool).await
}
@@ -162,7 +421,20 @@ pub async fn find_requests(
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
name,
url,
method,
body,
body_type,
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
authentication_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE workspace_id = ?
@@ -177,11 +449,23 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
name,
url,
method,
body,
body_type,
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
authentication_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE id = ?
ORDER BY created_at DESC
"#,
id,
)
@@ -190,9 +474,11 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
}
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let req = get_request(id, pool)
.await
.expect("Failed to get request to delete");
let req = get_request(id, pool).await?;
// DB deletes will cascade but this will delete the files
delete_all_responses(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM http_requests
@@ -212,19 +498,31 @@ pub async fn create_response(
url: &str,
status: i64,
status_reason: Option<&str>,
body: &str,
content_length: Option<i64>,
body: Option<Vec<u8>>,
body_path: Option<&str>,
headers: Vec<HttpResponseHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool)
.await
.expect("Failed to get request");
let id = generate_id("rp");
let req = get_request(request_id, pool).await?;
let id = generate_id(Some("rp"));
let headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO http_responses (
id,
request_id,
workspace_id,
elapsed,
url,
status,
status_reason,
content_length,
body,
body_path,
headers
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
@@ -233,47 +531,88 @@ pub async fn create_response(
url,
status,
status_reason,
content_length,
body,
body_path,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
.await?;
get_response(&id, pool).await
}
pub async fn update_response(
response: HttpResponse,
pub async fn update_response_if_id(
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(response.headers);
if response.id == "" {
return Ok(response.clone());
}
return update_response(response, pool).await;
}
pub async fn update_workspace(
workspace: Workspace,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
trimmed_name,
workspace.id,
)
.execute(pool)
.await?;
get_workspace(&workspace.id, pool).await
}
pub async fn update_response(
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(&response.headers);
sqlx::query!(
r#"
UPDATE http_responses SET (
elapsed,
url,
status,
status_reason,
content_length,
body,
body_path,
error,
headers,
updated_at
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.url,
response.status,
response.status_reason,
response.content_length,
response.body,
response.body_path,
response.error,
headers_json,
response.id,
)
.execute(pool)
.await
.expect("Failed to update response");
.await?;
get_response(&response.id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as_unchecked!(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,
status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
@@ -291,12 +630,12 @@ pub async fn find_responses(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at,
created_at, status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at ASC
ORDER BY created_at DESC
"#,
request_id,
)
@@ -304,7 +643,36 @@ pub async fn find_responses(
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
pub async fn find_responses_by_workspace_id(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE workspace_id = ?
ORDER BY created_at DESC
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
let resp = get_response(id, pool).await?;
// Delete the body file if it exists
if let Some(p) = resp.body_path.clone() {
if let Err(e) = fs::remove_file(p) {
println!("Failed to delete body file: {}", e);
};
}
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
@@ -315,29 +683,23 @@ pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::
.execute(pool)
.await;
Ok(())
Ok(resp)
}
pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE request_id = ?
"#,
request_id,
)
.execute(pool)
.await;
for r in find_responses(request_id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(())
}
pub fn generate_id(prefix: &str) -> String {
format!(
"{prefix}_{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
)
pub fn generate_id(prefix: Option<&str>) -> String {
let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 10);
return match prefix {
None => id,
Some(p) => format!("{p}_{id}"),
};
}

97
src-tauri/src/plugin.rs Normal file
View File

@@ -0,0 +1,97 @@
use boa_engine::{
js_string,
module::{ModuleLoader, SimpleModuleLoader},
property::Attribute,
Context, JsArgs, JsNativeError, JsValue, Module, NativeFunction, Source,
};
use boa_runtime::Console;
use tauri::AppHandle;
pub fn test_plugins(app_handle: &AppHandle) {
let plugin_dir = app_handle
.path_resolver()
.resolve_resource("plugins/hello-world")
.expect("failed to resolve plugin directory resource");
let plugin_entry_file = app_handle
.path_resolver()
.resolve_resource("plugins/hello-world/index.js")
.expect("failed to resolve plugin entry point resource");
// Module loader for the specific plugin
let loader = &SimpleModuleLoader::new(plugin_dir).expect("failed to create module loader");
let dyn_loader: &dyn ModuleLoader = loader;
let context = &mut Context::builder()
.module_loader(dyn_loader)
.build()
.expect("failed to create context");
add_runtime(context);
add_globals(context);
let source = Source::from_filepath(&plugin_entry_file).expect("Error opening file");
// Can also pass a `Some(realm)` if you need to execute the module in another realm.
let module = Module::parse(source, None, context).expect("failed to parse module");
// Insert parsed entrypoint into the module loader
// TODO: Is this needed if loaded from file already?
loader.insert(plugin_entry_file, module.clone());
let _promise_result = module
.load_link_evaluate(context)
.expect("failed to evaluate module");
// Very important to push forward the job queue after queueing promises.
context.run_jobs();
// // Checking if the final promise didn't return an error.
// match promise_result.state() {
// PromiseState::Pending => return Err("module didn't execute!".into()),
// PromiseState::Fulfilled(v) => {
// assert_eq!(v, JsValue::undefined())
// }
// PromiseState::Rejected(err) => {
// return Err(JsError::from_opaque(err).try_native(context)?.into())
// }
// }
let namespace = module.namespace(context);
let entrypoint_fn = namespace
.get(js_string!("entrypoint"), context)
.expect("failed to get entrypoint")
.as_callable()
.cloned()
.ok_or_else(|| JsNativeError::typ().with_message("export wasn't a function!"))
.expect("Failed to get entrypoint");
// Actually call the entrypoint function
let _result = entrypoint_fn
.call(&JsValue::undefined(), &[], context)
.expect("Failed to call entrypoint");
}
fn add_runtime(context: &mut Context<'_>) {
let console = Console::init(context);
context
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
.expect("the console builtin shouldn't exist");
}
fn add_globals(context: &mut Context<'_>) {
context
.register_global_builtin_callable(
"sayHello",
1,
NativeFunction::from_fn_ptr(|_, args, context| {
let value: String = args
.get_or_undefined(0)
.try_js_into(context)
.expect("failed to convert arg");
println!("Hello {}!", value);
Ok(value.into())
}),
)
.expect("failed to register global");
}

29
src-tauri/src/render.rs Normal file
View File

@@ -0,0 +1,29 @@
use crate::models::Environment;
use std::collections::HashMap;
use tauri::regex::Regex;
pub fn render(template: &str, environment: Option<&Environment>) -> String {
match environment {
Some(environment) => render_with_environment(template, environment),
None => template.to_string(),
}
}
fn render_with_environment(template: &str, environment: &Environment) -> String {
let mut map = HashMap::new();
let variables = &environment.variables.0;
for variable in variables {
if !variable.enabled {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
}
Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}")
.expect("Failed to create regex")
.replace_all(template, |caps: &tauri::regex::Captures| {
let key = caps.get(1).unwrap().as_str();
map.get(key).unwrap_or(&"")
})
.to_string()
}

View File

@@ -1,16 +0,0 @@
(function (globalThis) {
// Deno.core.print(Object.keys(Deno.core).join('\n'));
function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(' ');
}
globalThis.console = {
log: (...args) => {
Deno.core.print(`[log]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
Deno.core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
};
})(globalThis);

View File

@@ -1,117 +0,0 @@
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
use deno_core::error::AnyError;
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
use std::rc::Rc;
use deno_core::futures::FutureExt;
use futures::executor;
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
executor::block_on(run_plugin(file_path))
}
pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
let extension = Extension::builder("runtime")
.ops(vec![op_hello::decl()])
.build();
// Initialize a runtime instance
let mut runtime = JsRuntime::new(RuntimeOptions {
module_loader: Some(Rc::new(TsModuleLoader)),
extensions: vec![extension],
..Default::default()
});
runtime
.execute_script("<runtime>", include_str!("runtime.js"))
.expect("Failed to execute runtime.js");
let main_module = deno_core::resolve_path(file_path).expect("Failed to resolve path");
let mod_id = runtime
.load_main_module(&main_module, None)
.await
.expect("Failed to load main module");
let result = runtime.mod_evaluate(mod_id);
runtime
.run_event_loop(false)
.await
.expect("Failed to run event loop");
result.await?
}
#[op]
async fn op_hello(name: String) -> Result<String, AnyError> {
let contents = format!("Hello {} from Rust!", name);
println!("{}", contents);
Ok(contents)
}
struct TsModuleLoader;
impl deno_core::ModuleLoader for TsModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: deno_core::ResolutionKind,
) -> Result<deno_core::ModuleSpecifier, AnyError> {
deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
}
fn load(
&self,
module_specifier: &deno_core::ModuleSpecifier,
_maybe_referrer: Option<deno_core::ModuleSpecifier>,
_is_dyn_import: bool,
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
let module_specifier = module_specifier.clone();
async move {
let path = module_specifier.to_file_path().unwrap();
// Determine what the MediaType is (this is done based on the file
// extension) and whether transpiling is required.
let media_type = MediaType::from(&path);
let (module_type, should_transpile) = match MediaType::from(&path) {
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
(ModuleType::JavaScript, false)
}
MediaType::Jsx => (ModuleType::JavaScript, true),
MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Dts
| MediaType::Dmts
| MediaType::Dcts
| MediaType::Tsx => (ModuleType::JavaScript, true),
MediaType::Json => (ModuleType::Json, false),
_ => panic!("Unknown extension {:?}", path.extension()),
};
// Read the file, transpile if necessary.
let code = std::fs::read_to_string(&path)?;
let code = if should_transpile {
let parsed = deno_ast::parse_module(ParseParams {
specifier: module_specifier.to_string(),
text_info: SourceTextInfo::from_string(code),
media_type,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})?;
parsed.transpile(&Default::default())?.text
} else {
code
};
// Load and return module.
let module = ModuleSource {
code: code.into_bytes().into_boxed_slice(),
module_type,
module_url_specified: module_specifier.to_string(),
module_url_found: module_specifier.to_string(),
};
Ok(module)
}
.boxed_local()
}
}

View File

@@ -1,13 +1,18 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 22.0;
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
pub trait WindowExt {
pub trait TrafficLightWindowExt {
fn position_traffic_lights(&self);
}
impl<R: Runtime> WindowExt for Window<R> {
impl<R: Runtime> TrafficLightWindowExt for Window<R> {
#[cfg(not(target_os = "macos"))]
fn position_traffic_lights(&self) {
// No-op
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};

View File

@@ -8,25 +8,26 @@
},
"package": {
"productName": "Yaak",
"version": "0.0.2"
"version": "2023.1.7"
},
"tauri": {
"windows": [
{
"fullscreen": false,
"height": 800,
"hiddenTitle": true,
"resizable": true,
"title": "Yaak",
"titleBarStyle": "Overlay",
"width": 1400
}
],
"windows": [],
"allowlist": {
"all": false,
"os": {
"all": true
},
"protocol": {
"assetScope": [
"$APPDATA/responses/*"
],
"asset": true
},
"fs": {
"readFile": true,
"scope": [
"$RESOURCE/*"
"$RESOURCE/*",
"$APPDATA/responses/*"
]
},
"shell": {
@@ -50,18 +51,26 @@
"icons/icon.ico"
],
"identifier": "co.schier.yaak",
"longDescription": "",
"longDescription": "The best cross-platform visual API client",
"resources": [
"plugins/*",
"migrations/*"
"migrations/*",
"plugins/*"
],
"shortDescription": "The best API client",
"targets": [
"deb",
"appimage",
"nsis",
"app",
"dmg",
"updater"
],
"shortDescription": "",
"targets": "all",
"deb": {
"depends": []
},
"macOS": {
"exceptionDomain": "",
"entitlements": "macos/entitlements.plist",
"frameworks": []
},
"windows": {
@@ -78,7 +87,7 @@
"active": true,
"dialog": true,
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{current_version}}"
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}

View File

@@ -0,0 +1,5 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path fill="currentColor"
d="M2.5,1C1.672,1 1,1.672 1,2.5L1,12.5C1,13.328 1.672,14 2.5,14L12.5,14C13.328,14 14,13.328 14,12.5L14,2.5C14,1.672 13.328,1 12.5,1L2.5,1ZM12.5,13C12.776,13 13,12.776 13,12.5L13,2.5C13,2.224 12.776,2 12.5,2L6,2L6,13L12.5,13ZM2.5,2L5,2L5,13L2.5,13C2.224,13 2,12.776 2,12.5L2,2.5C2,2.224 2.224,2 2.5,2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,6 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect x="0" y="0" width="15" height="15" style="fill:none;"/>
<g transform="matrix(1,0,0,1,-16,-8.88178e-16)">
<path fill="currentColor" d="M18.5,1C17.672,1 17,1.672 17,2.5L17,12.5C17,13.328 17.672,14 18.5,14L28.5,14C29.328,14 30,13.328 30,12.5L30,2.5C30,1.672 29.328,1 28.5,1L18.5,1ZM28.5,13C28.776,13 29,12.776 29,12.5L29,2.5C29,2.224 28.776,2 28.5,2L22,2L22,13L28.5,13ZM18,11.535L21,12.285L21,13L18.5,13C18.224,13 18,12.776 18,12.5L18,11.535ZM18,10.504L21,11.254L21,9.81L18,9.06L18,10.504ZM18,8.029L21,8.779L21,7.327L18,6.577L18,8.029ZM18,5.546L21,6.296L21,4.833L18,4.083L18,5.546ZM21,3.802L18,3.052L18,2.5C18,2.224 18.224,2 18.5,2L21,2L21,3.802Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

View File

@@ -1,79 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { MotionConfig } from 'framer-motion';
import { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { requestsQueryKey } from '../hooks/useRequests';
import { responsesQueryKey } from '../hooks/useResponses';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
const queryClient = new QueryClient();
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
const newRequests = [];
let found = false;
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
}
return newRequests;
const queryClient = new QueryClient({
logger: undefined,
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: true,
networkMode: 'offlineFirst',
},
);
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
const newResponses = [];
let found = false;
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
}
return newResponses;
},
);
});
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
},
});
export function App() {
@@ -81,7 +22,12 @@ export function App() {
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
<DndProvider backend={HTML5Backend}>
<Suspense>
<AppRouter />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</Suspense>
</DndProvider>
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>

View File

@@ -1,25 +1,41 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Workspaces = lazy(() => import('./Workspaces'));
const Workspace = lazy(() => import('./Workspace'));
const RouteError = lazy(() => import('./RouteError'));
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { GlobalHooks } from './GlobalHooks';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
import { DialogProvider } from './DialogContext';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import RouteError from './RouteError';
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <Layout />,
children: [
{
path: '/',
element: <Navigate to={routePaths.workspaces()} replace={true} />,
},
{
path: routePaths.workspaces(),
element: <Workspaces />,
},
{
path: '/workspaces/:workspaceId',
element: <Workspace />,
path: routePaths.workspace({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
}),
element: <WorkspaceOrRedirect />,
},
{
path: '/workspaces/:workspaceId/requests/:requestId',
path: routePaths.request({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
requestId: ':requestId',
}),
element: <Workspace />,
},
],
@@ -27,9 +43,39 @@ const router = createBrowserRouter([
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}
function WorkspaceOrRedirect() {
const recentRequests = useRecentRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const request = requests.find((r) => r.id === recentRequests[0]);
const routes = useAppRoutes();
if (request === undefined) {
return <Workspace />;
}
const { id: requestId, workspaceId } = request;
const environmentId = activeEnvironmentId ?? undefined;
return (
<Suspense>
<RouterProvider router={router} />
</Suspense>
<Navigate
to={routes.paths.request({
workspaceId,
environmentId,
requestId,
})}
/>
);
}
function Layout() {
return (
<DialogProvider>
<Outlet />
<GlobalHooks />
</DialogProvider>
);
}

View File

@@ -0,0 +1,45 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
requestId: string;
authentication: HttpRequest['authentication'];
}
export function BasicAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
return (
<VStack className="my-2" space={2}>
<Input
useTemplating
label="Username"
name="username"
size="sm"
defaultValue={`${authentication.username}`}
onChange={(username: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { password: r.authentication.password, username },
}));
}}
/>
<Input
useTemplating
label="Password"
name="password"
size="sm"
type="password"
defaultValue={`${authentication.password}`}
onChange={(password: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { username: r.authentication.username, password },
}));
}}
/>
</VStack>
);
}

View File

@@ -0,0 +1,33 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
requestId: string;
authentication: HttpRequest['authentication'];
}
export function BearerAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
return (
<VStack className="my-2" space={2}>
<Input
useTemplating
autocompleteVariables
type="password"
label="Token"
name="token"
size="sm"
defaultValue={`${authentication.token}`}
onChange={(token: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { token },
}));
}}
/>
</VStack>
);
}

View File

@@ -0,0 +1,65 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };
interface State {
dialogs: DialogEntry[];
actions: Actions;
}
interface Actions {
show: (d: DialogEntryOptionalId) => void;
hide: (id: string) => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DialogContext = createContext<State>({} as any);
export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
const actions = useMemo<Actions>(
() => ({
show: ({ id: oid, ...props }: DialogEntryOptionalId) => {
const id = oid ?? Math.random().toString(36).slice(2);
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
},
hide: (id: string) => {
setDialogs((a) => a.filter((d) => d.id !== id));
},
}),
[],
);
const state: State = {
dialogs,
actions,
};
return (
<DialogContext.Provider value={state}>
{children}
{dialogs.map((props: DialogEntry) => (
<DialogInstance key={props.id} {...props} />
))}
</DialogContext.Provider>
);
};
function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext);
const children = render({ hide: () => actions.hide(id) });
return (
<Dialog open onClose={() => actions.hide(id)} {...props}>
{children}
</Dialog>
);
}
export const useDialog = () => useContext(DialogContext).actions;

View File

@@ -0,0 +1,22 @@
import classNames from 'classnames';
import React, { memo } from 'react';
interface Props {
className?: string;
}
export const DropMarker = memo(
function DropMarker({ className }: Props) {
return (
<div
className={classNames(
className,
'relative w-full h-0 overflow-visible pointer-events-none',
)}
>
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-blue-500/50 rounded-full" />
</div>
);
},
() => true,
);

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
}
export function EmptyStateText({ children }: Props) {
return (
<div className="rounded-lg border border-dashed border-highlight h-full text-gray-400 flex items-center justify-center">
{children}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { useEnvironments } from '../hooks/useEnvironments';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { usePrompt } from '../hooks/usePrompt';
type Props = {
className?: string;
};
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className,
}: Props) {
const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
title: 'Manage Environments',
render: () => <EnvironmentEditDialog />,
});
}, [dialog]);
const items: DropdownItem[] = useMemo(
() =>
environments.length === 0
? [
{
key: 'create',
label: 'Create Environment',
leftSlot: <Icon icon="plusCircle" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
},
},
]
: [
...environments.map(
(e) => ({
key: e.id,
label: e.name,
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
onSelect: async () => {
routes.setEnvironment(e);
},
}),
[activeEnvironment?.id],
),
{ type: 'separator', label: 'Environments' },
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
],
[activeEnvironment, environments, routes, createEnvironment, showEnvironmentDialog],
);
return (
<Dropdown items={items}>
<Button
forDropdown
size="sm"
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeEnvironment == null && 'text-opacity-disabled italic',
)}
>
{activeEnvironment?.name ?? 'No Environment'}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,162 @@
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import type { Environment } from '../lib/models';
import { Button } from './core/Button';
import classNames from 'classnames';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { PairEditor } from './core/PairEditor';
import type { PairEditorProps } from './core/PairEditor';
import { useCallback, useMemo } from 'react';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { HStack, VStack } from './core/Stacks';
import { IconButton } from './core/IconButton';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { usePrompt } from '../hooks/usePrompt';
import { InlineCode } from './core/InlineCode';
import { useWindowSize } from 'react-use';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
export const EnvironmentEditDialog = function () {
const routes = useAppRoutes();
const environments = useEnvironments();
const createEnvironment = useCreateEnvironment();
const activeEnvironment = useActiveEnvironment();
const windowSize = useWindowSize();
const showSidebar = windowSize.width > 500;
return (
<div
className={classNames(
'h-full grid gap-x-8 grid-rows-[minmax(0,1fr)]',
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)}
>
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-4 border-r border-gray-100">
<div className="min-w-0 h-full w-full overflow-y-scroll">
{environments.map((e) => (
<Button
size="xs"
color="custom"
className={classNames(
'w-full',
'text-gray-600 hocus:text-gray-800',
activeEnvironment?.id === e.id && 'bg-highlightSecondary !text-gray-900',
)}
justify="start"
key={e.id}
onClick={() => {
routes.setEnvironment(e);
}}
>
{e.name}
</Button>
))}
</div>
<Button
size="sm"
className="w-full"
color="gray"
onClick={() => createEnvironment.mutate()}
>
New Environment
</Button>
</aside>
)}
{activeEnvironment != null ? (
<EnvironmentEditor environment={activeEnvironment} />
) : (
<div className="flex w-full h-full items-center justify-center text-gray-400 italic">
select an environment
</div>
)}
</div>
);
};
const EnvironmentEditor = function ({ environment }: { environment: Environment }) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment.id);
const deleteEnvironment = useDeleteEnvironment(environment);
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => {
updateEnvironment.mutate({ variables });
},
[updateEnvironment],
);
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const allVariableNames = environments.flatMap((e) => e.variables.map((v) => v.name));
// Filter out empty strings and variables that already exist in the active environment
const variableNames = allVariableNames.filter(
(name) => name != '' && !environment.variables.find((v) => v.name === name),
);
return { options: variableNames.map((name) => ({ label: name, type: 'constant' })) };
}, [environments, environment.variables]);
const prompt = usePrompt();
const items = useMemo<DropdownItem[]>(
() => [
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: environment.name,
});
updateEnvironment.mutate({ name });
},
},
{
key: 'delete',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
},
],
[deleteEnvironment, updateEnvironment, environment.name, prompt],
);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_]*$/i) != null;
}, []);
return (
<VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment.name}</h1>
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
</Dropdown>
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment.id}
pairs={environment.variables}
onChange={handleChange}
/>
</VStack>
);
};

View File

@@ -0,0 +1,138 @@
import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { setPathname } from '../lib/persistPathname';
export function GlobalHooks() {
// Include here so they always update, even
// if no component references them
useRecentWorkspaces();
useRecentEnvironments();
useRecentRequests();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
// Listen for location changes and update the pathname
const location = useLocation();
useEffect(() => {
setPathname(location.pathname);
}, [location.pathname]);
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
if (queryKey === null) {
console.log('Unrecognized created model:', payload);
return;
}
if (!shouldIgnoreModel(payload)) {
// Order newest first
queryClient.setQueryData<Model[]>(queryKey, (values) => [payload, ...(values ?? [])]);
}
});
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
if (queryKey === null) {
console.log('Unrecognized updated model:', payload);
return;
}
if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id);
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(
queryKey,
(values) => values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});
useListenToTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
if (shouldIgnoreModel(payload)) return;
if (payload.model === 'workspace') {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
} else if (payload.model === 'http_request') {
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'http_response') {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
}
});
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
if (windowLabel !== appWindow.label) return;
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
});
return null;
}
function removeById<T extends { id: string }>(model: T) {
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
}
const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
windowLabel === appWindow.label && payload.model !== 'http_response';
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC;
}
return false;
};

View File

@@ -0,0 +1,135 @@
import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor';
import { Editor, formatGraphQL } from './core/Editor';
import { FormattedError } from './core/FormattedError';
import { Separator } from './core/Separator';
import { useDialog } from './DialogContext';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
baseRequest: HttpRequest;
};
interface GraphQLBody {
query: string;
variables?: Record<string, string | number | boolean | null>;
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const { schema, isLoading, error, refetch } = useIntrospectGraphQL(baseRequest);
const { query, variables } = useMemo<GraphQLBody>(() => {
if (defaultValue === undefined) {
return { query: '', variables: {} };
}
try {
const p = JSON.parse(defaultValue ?? '{}');
const query = p.query ?? '';
const variables = p.variables;
const operationName = p.operationName;
return { query, variables, operationName };
} catch (err) {
return { query: 'failed to parse' };
}
}, [defaultValue]);
const handleChange = useCallback(
(b: GraphQLBody) => onChange?.(JSON.stringify(b, null, 2)),
[onChange],
);
const handleChangeQuery = useCallback(
(query: string) => handleChange({ query, variables }),
[handleChange, variables],
);
const handleChangeVariables = useCallback(
(variables: string) => {
try {
handleChange({ query, variables: JSON.parse(variables) });
} catch (e) {
// Meh, not much we can do here
}
},
[handleChange, query],
);
// Refetch the schema when the URL changes
useEffect(() => {
if (editorViewRef.current === null) return;
updateSchema(editorViewRef.current, schema);
}, [schema]);
const dialog = useDialog();
return (
<div className="pb-2 h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}
format={formatGraphQL}
heightMode="auto"
onChange={handleChangeQuery}
placeholder="..."
ref={editorViewRef}
actions={
(error || isLoading) && (
<Button
size="xs"
color={error ? 'danger' : 'gray'}
isLoading={isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'sm',
id: 'introspection-failed',
render: () => (
<div className="whitespace-pre-wrap">
<FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full mt-3">
<Button
onClick={() => {
dialog.hide('introspection-failed');
refetch();
}}
className="ml-auto"
color="secondary"
size="sm"
>
Try Again
</Button>
</div>
</div>
),
});
}}
>
{error ? 'Introspection Failed' : 'Introspecting'}
</Button>
)
}
{...extraEditorProps}
/>
<Separator variant="primary" />
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor
contentType="application/json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
autocompleteVariables
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -1,117 +1,71 @@
import classnames from 'classnames';
import { useEffect, useState } from 'react';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpHeader, HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
import { charsets } from '../lib/data/charsets';
import { connections } from '../lib/data/connections';
import { encodings } from '../lib/data/encodings';
import { headerNames } from '../lib/data/headerNames';
import { mimeTypes } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
interface Props {
request: HttpRequest;
className?: string;
}
type PairWithId = { header: Partial<HttpHeader>; id: string };
export function HeaderEditor({ request, className }: Props) {
const updateRequest = useUpdateRequest(request);
const saveHeaders = (pairs: PairWithId[]) => {
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
updateRequest.mutate({ headers });
};
const newPair = () => {
return { header: { name: '', value: '' }, id: Math.random().toString() };
};
const [pairs, setPairs] = useState<PairWithId[]>(
request.headers.map((h) => ({ header: h, id: Math.random().toString() })),
);
const setPairsAndSave = (fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const newPairs = fn(oldPairs);
saveHeaders(newPairs);
return newPairs;
});
};
const handleChangeHeader = (pair: PairWithId) => {
setPairsAndSave((pairs) =>
pairs.map((p) =>
pair.id !== p.id ? p : { id: p.id, header: { ...p.header, ...pair.header } },
),
);
};
useEffect(() => {
const lastPair = pairs[pairs.length - 1];
if (lastPair === undefined) {
setPairs([newPair()]);
return;
}
if (lastPair.header.name !== '' || lastPair.header.value !== '') {
setPairsAndSave((pairs) => [...pairs, newPair()]);
}
}, [pairs]);
const handleDelete = (pair: PairWithId) => {
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
};
type Props = {
forceUpdateKey: string;
headers: HttpRequest['headers'];
onChange: (headers: HttpRequest['headers']) => void;
};
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return (
<div className={classnames(className, 'pb-6 grid')}>
<VStack space={2}>
{pairs.map((p, i) => (
<FormRow
key={p.id}
pair={p}
onChange={handleChangeHeader}
onDelete={i < pairs.length - 1 ? handleDelete : undefined}
/>
))}
</VStack>
</div>
<PairEditor
valueAutocompleteVariables
nameAutocompleteVariables
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder="Header-Name"
/>
);
}
function FormRow({
pair,
onChange,
onDelete,
}: {
pair: PairWithId;
onChange: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId) => void;
}) {
return (
<div className="group grid grid-cols-[1fr_1fr_2.5rem] grid-rows-1 gap-2 items-center">
<Input
hideLabel
useEditor={{ useTemplating: true }}
name="name"
label="Name"
placeholder="name"
defaultValue={pair.header.name}
onChange={(name) => onChange({ id: pair.id, header: { name } })}
/>
<Input
hideLabel
name="value"
label="Value"
useEditor={{ useTemplating: true }}
placeholder="value"
defaultValue={pair.header.value}
onChange={(value) => onChange({ id: pair.id, header: { value } })}
/>
{onDelete && (
<IconButton
icon="trash"
onClick={() => onDelete(pair)}
className="invisible group-hover:visible"
/>
)}
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
'content-type': mimeTypes,
accept: ['*/*', ...mimeTypes],
'accept-encoding': encodings,
connection: connections,
'accept-charset': charsets,
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionConfig['options'] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',
boost: 1, // Put above other completions
})) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) => ({
label: t,
type: 'constant',
boost: 1, // Put above other completions
})),
};
const validateHttpHeader = (v: string) => {
if (v === '') {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, '123');
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};

View File

@@ -0,0 +1,59 @@
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { Portal } from './Portal';
interface Props {
children: ReactNode;
portalName: string;
open: boolean;
onClose?: () => void;
zIndex?: keyof typeof zIndexes;
variant?: 'default' | 'transparent';
}
const zIndexes: Record<number, string> = {
10: 'z-10',
20: 'z-20',
30: 'z-30',
40: 'z-40',
50: 'z-50',
};
export function Overlay({
variant = 'default',
zIndex = 30,
open,
onClose,
portalName,
children,
}: Props) {
return (
<Portal name={portalName}>
{open && (
<FocusTrap>
<motion.div
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div
aria-hidden
onClick={onClose}
className={classNames(
'absolute inset-0',
variant === 'default' && 'bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm',
)}
/>
{/* Add region to still be able to drag the window */}
{variant !== 'transparent' && (
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
)}
<div className="bg-red-100">{children}</div>
</motion.div>
</FocusTrap>
)}
</Portal>
);
}

View File

@@ -0,0 +1,19 @@
import type { HttpRequest } from '../lib/models';
import { PairEditor } from './core/PairEditor';
type Props = {
forceUpdateKey: string;
parameters: { name: string; value: string }[];
onChange: (headers: HttpRequest['headers']) => void;
};
export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
return (
<PairEditor
forceUpdateKey={forceUpdateKey}
pairs={parameters}
onChange={onChange}
namePlaceholder="name"
/>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePortal } from '../hooks/usePortal';
interface Props {
children: ReactNode;
name: string;
}
export function Portal({ children, name }: Props) {
const portal = usePortal(name);
return createPortal(children, portal);
}

View File

@@ -0,0 +1,98 @@
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import classNames from 'classnames';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
export function RecentRequestsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
// Toggle the menu on Cmd+k
useKey('k', (e) => {
if (e.metaKey) {
e.preventDefault();
dropdownRef.current?.toggle(0);
}
});
// Handle key-up
useKeyPressEvent('Control', undefined, () => {
dropdownRef.current?.select?.();
});
useKey(
'Tab',
(e) => {
if (!e.ctrlKey || recentRequestIds.length === 0) return;
if (!dropdownRef.current?.isOpen) {
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
return;
}
if (e.shiftKey) dropdownRef.current?.prev?.();
else dropdownRef.current?.next?.();
},
undefined,
[recentRequestIds.length],
);
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);
if (request === undefined) continue;
recentRequestItems.push({
key: request.id,
label: request.name,
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
environmentId: activeEnvironmentId ?? undefined,
workspaceId: activeWorkspaceId,
});
},
});
}
// No recent requests to show
if (recentRequestItems.length === 0) {
return [];
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
size="sm"
className={classNames(
'flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none',
activeRequest === null && 'text-opacity-disabled italic',
)}
>
{activeRequest?.name ?? 'No Request'}
</Button>
</Dropdown>
);
}

View File

@@ -0,0 +1,64 @@
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { pluralize } from '../lib/pluralize';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
interface Props {
responses: HttpResponse[];
activeResponse: HttpResponse;
onPinnedResponse: (r: HttpResponse) => void;
}
export const RecentResponsesDropdown = function ResponsePane({
activeResponse,
responses,
onPinnedResponse,
}: Props) {
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
return (
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 20).map((r) => ({
key: r.id,
label: (
<HStack space={2} alignItems="center">
<StatusTag className="text-xs" response={r} />
<span>&bull;</span> <span className="font-mono text-xs">{r.elapsed}ms</span>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponse(r),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
);
};

View File

@@ -0,0 +1,62 @@
import type { HTMLAttributes, ReactElement } from 'react';
import React, { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
interface Props {
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
return (
<Dropdown
ref={dropdownRef}
items={[
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
]}
>
{children}
</Dropdown>
);
}

View File

@@ -0,0 +1,28 @@
import { memo } from 'react';
import { Button } from './core/Button';
import { RadioDropdown } from './core/RadioDropdown';
type Props = {
method: string;
className?: string;
onChange: (method: string) => void;
};
const methodItems = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].map((m) => ({
value: m,
label: m,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
method,
onChange,
className,
}: Props) {
return (
<RadioDropdown value={method} items={methodItems} onChange={onChange}>
<Button size="xs" className={className}>
{method.toUpperCase()}
</Button>
</RadioDropdown>
);
});

View File

@@ -1,64 +1,245 @@
import classnames from 'classnames';
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSendRequest } from '../hooks/useSendRequest';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
import {
AUTH_TYPE_BASIC,
AUTH_TYPE_BEARER,
AUTH_TYPE_NONE,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_XML,
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import { HeaderEditor } from './HeaderEditor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { GraphQLEditor } from './GraphQLEditor';
import { HeaderEditor } from './HeaderEditor';
import { ParametersEditor } from './ParameterEditor';
import { UrlBar } from './UrlBar';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
interface Props {
style?: CSSProperties;
fullHeight: boolean;
className?: string;
}
export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const updateRequest = useUpdateRequest(activeRequest);
const sendRequest = useSendRequest(activeRequest);
const useActiveTab = createGlobalState<string>('body');
if (activeRequest === null) return null;
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const activeEnvironmentId = useActiveEnvironmentId();
const updateRequest = useUpdateRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
const tabs: TabItem[] = useMemo(
() =>
activeRequest === null
? []
: [
{
value: 'body',
options: {
value: activeRequest.bodyType,
items: [
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ type: 'separator' },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType === BODY_TYPE_NONE) {
patch.headers = activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
);
} else if (bodyType == BODY_TYPE_GRAPHQL || bodyType === BODY_TYPE_JSON) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
) ?? []),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
}
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
await updateRequest.mutate(patch);
},
},
},
// { value: 'params', label: 'URL Params' },
{
value: 'headers',
label: (
<div className="flex items-center">
Headers
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
</div>
),
},
{
value: 'auth',
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest?.authentication;
if (authenticationType === AUTH_TYPE_BASIC) {
authentication = {
username: authentication.username ?? '',
password: authentication.password ?? '',
};
} else if (authenticationType === AUTH_TYPE_BEARER) {
authentication = {
token: authentication.token ?? '',
};
}
await updateRequest.mutate({ authenticationType, authentication });
},
},
},
],
[activeRequest, updateRequest],
);
const handleBodyChange = useCallback(
(body: string) => updateRequest.mutate({ body }),
[updateRequest],
);
const handleHeadersChange = useCallback(
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
[updateRequest],
);
useListenToTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', { requestId: activeRequestId, environmentId: activeEnvironmentId });
},
[activeRequestId, activeEnvironmentId],
);
return (
<div className={classnames(className, 'py-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<div className="pl-2">
<UrlBar
key={activeRequest.id}
method={activeRequest.method}
url={activeRequest.url}
loading={sendRequest.isLoading}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
sendRequest={sendRequest.mutate}
/>
</div>
<Tabs
tabs={[
{ value: 'body', label: 'JSON' },
{ value: 'params', label: 'Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
]}
className="mt-2"
tabListClassName="px-2"
defaultValue="body"
label="Request body"
>
<TabContent value="headers" className="pl-2">
<HeaderEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value="body">
<Editor
key={activeRequest.id}
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
useTemplating
defaultValue={activeRequest.body ?? ''}
contentType="application/graphql+json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</TabContent>
</Tabs>
<div
style={style}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
{activeRequest && (
<>
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
<BasicAuth
key={forceUpdateKey}
requestId={activeRequest.id}
authentication={activeRequest.authentication}
/>
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
<BearerAuth
key={forceUpdateKey}
requestId={activeRequest.id}
authentication={activeRequest.authentication}
/>
) : (
<EmptyStateText>
No Authentication {activeRequest.authenticationType}
</EmptyStateText>
)}
</TabContent>
<TabContent value="headers">
<HeaderEditor
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
/>
</TabContent>
<TabContent value="params">
<ParametersEditor
forceUpdateKey={forceUpdateKey}
parameters={[]}
onChange={() => null}
/>
</TabContent>
<TabContent value="body">
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''}
contentType="application/json"
onChange={handleBodyChange}
format={(v) => tryFormatJson(v)}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''}
contentType="text/xml"
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''}
onChange={handleBodyChange}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</TabContent>
</Tabs>
</>
)}
</div>
);
}
});

View File

@@ -0,0 +1,132 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { clamp } from '../lib/clamp';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
import { ResponsePane } from './ResponsePane';
interface Props {
style: CSSProperties;
}
const rqst = { gridArea: 'rqst' };
const resp = { gridArea: 'resp' };
const drag = { gridArea: 'drag' };
const DEFAULT = 0.5;
const MIN_WIDTH_PX = 10;
const MIN_HEIGHT_PX = 30;
const STACK_VERTICAL_WIDTH = 650;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
const width = widthRaw ?? DEFAULT;
const height = heightRaw ?? DEFAULT;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useResizeObserver(containerRef, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
});
const styles = useMemo<CSSProperties>(
() => ({
...style,
gridTemplate: vertical
? `
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
' ${drag.gridArea}' 0
' ${resp.gridArea}' minmax(0,${height}fr)
/ 1fr
`
: `
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
}),
[vertical, width, height, style],
);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleReset = useCallback(
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
[setHeight, vertical, setWidth],
);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (containerRef.current === null) return;
unsub();
const containerRect = containerRef.current.getBoundingClientRect();
const mouseStartX = e.clientX;
const mouseStartY = e.clientY;
const startWidth = containerRect.width * width;
const startHeight = containerRect.height * height;
moveState.current = {
move: (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
if (vertical) {
const maxHeightPx = containerRect.height - MIN_HEIGHT_PX;
const newHeightPx = clamp(
startHeight - (e.clientY - mouseStartY),
MIN_HEIGHT_PX,
maxHeightPx,
);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
MIN_WIDTH_PX,
maxWidthPx,
);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, setHeight, setWidth],
);
return (
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
<RequestPane style={rqst} fullHeight={!vertical} />
<ResizeHandle
style={drag}
isResizing={isResizing}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
<ResponsePane style={resp} />
</div>
);
});

View File

@@ -0,0 +1,57 @@
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React from 'react';
interface ResizeBarProps {
style?: CSSProperties;
className?: string;
barClassName?: string;
isResizing: boolean;
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
onReset?: () => void;
side: 'left' | 'right' | 'top';
justify: 'center' | 'end' | 'start';
}
export function ResizeHandle({
style,
justify,
className,
onResizeStart,
onReset,
isResizing,
side,
}: ResizeBarProps) {
const vertical = side === 'top';
return (
<div
aria-hidden
draggable
style={style}
className={classNames(
className,
'group z-10 flex',
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'top' && 'top-0',
)}
onDragStart={onResizeStart}
onDoubleClick={onReset}
>
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
{isResizing && (
<div
className={classNames(
'fixed -left-20 -right-20 -top-20 -bottom-20',
vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize',
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames';
import type { HttpResponse } from '../lib/models';
import { HStack } from './core/Stacks';
interface Props {
headers: HttpResponse['headers'];
}
export function ResponseHeaders({ headers }: Props) {
return (
<dl className="text-xs w-full h-full font-mono overflow-auto">
{headers.map((h, i) => {
return (
<HStack
space={3}
key={i}
className={classNames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
>
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
</HStack>
);
})}
</dl>
);
}

View File

@@ -1,131 +1,163 @@
import classnames from 'classnames';
import { memo, useEffect, useMemo, useState } from 'react';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteResponse } from '../hooks/useResponseDelete';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { useCallback, memo, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
import { tryFormatJson } from '../lib/formatters';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { DurationTag } from './core/DurationTag';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { Webview } from './core/Webview';
import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { ResponseHeaders } from './ResponseHeaders';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
interface Props {
style?: CSSProperties;
className?: string;
}
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses();
const activeResponse: HttpResponse | null = activeResponseId
? responses.find((r) => r.id === activeResponseId) ?? null
: responses[responses.length - 1] ?? null;
const deleteResponse = useDeleteResponse(activeResponse);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const useActiveTab = createGlobalState<string>('body');
useEffect(() => {
setActiveResponseId(null);
}, [responses.length]);
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const latestResponse = useLatestResponse(activeRequestId);
const responses = useResponses(activeRequestId);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useResponseContentType(activeResponse);
const handlePinnedResponse = useCallback((r: HttpResponse) => {
setPinnedResponseId(r.id);
}, [setPinnedResponseId])
const tabs: TabItem[] = useMemo(
() => [
{
value: 'body',
label: 'Preview',
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: 'Pretty', value: 'pretty' },
{ label: 'Raw', value: 'raw' },
],
},
},
{
label: (
<div className="flex items-center">
Headers
<CountBadge
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
/>
</div>
),
value: 'headers',
},
],
[activeResponse?.headers, setViewMode, viewMode],
);
if (activeResponse === null) {
return null;
}
return (
<div className="p-2">
<div
className={classnames(
className,
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
'dark:bg-gray-100 rounded-md overflow-hidden border border-gray-200',
'shadow shadow-gray-100 dark:shadow-gray-0',
)}
>
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
{/*</HStack>*/}
<HStack
alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
>
{activeResponse && activeResponse.status > 0 && (
<div className="whitespace-nowrap">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
)}
<div
style={style}
className={classNames(
className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
<>
<HStack
alignItems="center"
className={classNames(
'text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<HStack alignItems="center" className="w-full">
<div className="whitespace-nowrap px-3">
<HStack space={2}>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag millis={activeResponse.elapsed} />
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
</HStack>
</div>
<HStack alignItems="center" className="ml-auto h-8">
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-1"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: 'Clear All Responses',
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
'-----',
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
})),
]}
>
<IconButton icon="clock" className="ml-auto" size="sm" />
</Dropdown>
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={handlePinnedResponse}
/>
</HStack>
)}
</HStack>
</HStack>
{activeResponse?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</div>
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
<TabContent value="body">
{!activeResponse.contentLength ? (
<EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.startsWith('image') ? (
<ImageViewer className="pb-2" response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
)}
</TabContent>
</Tabs>
</>
)}
</div>
);
});

View File

@@ -1,22 +1,35 @@
import { useRouteError } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
console.log("Error", error);
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
const routes = useAppRoutes();
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
<VStack space={5} className="max-w-[50rem] !h-auto">
<Heading>Route Error 🔥</Heading>
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
{message}
</pre>
<Button to="/" color="primary">
Go Home
</Button>
<FormattedError>{message}</FormattedError>
<VStack space={2}>
<Button
color="primary"
onClick={() => {
routes.navigate('workspaces');
}}
>
Go Home
</Button>
<Button color="secondary" onClick={() => window.location.reload()}>
Refresh
</Button>
</VStack>
</VStack>
</div>
);

View File

@@ -1,118 +1,433 @@
import classnames from 'classnames';
import { useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import classNames from 'classnames';
import type { ForwardedRef } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useRequests } from '../hooks/useRequests';
import { useTheme } from '../hooks/useTheme';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { isResponseLoading } from '../lib/models';
import { Icon } from './core/Icon';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { VStack } from './core/Stacks';
interface Props {
className?: string;
}
export function Sidebar({ className }: Props) {
const requests = useRequests();
const activeRequest = useActiveRequest();
const deleteRequest = useDeleteRequest(activeRequest);
const createRequest = useCreateRequest({ navigateAfter: true });
const { appearance, toggleAppearance } = useTheme();
return (
<div
className={classnames(
className,
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
)}
>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
<IconButton
className="mx-1"
icon="plusCircle"
onClick={async () => {
await createRequest.mutate({ name: 'Test Request' });
}}
/>
</HStack>
<VStack as="ul" className="py-3 px-2 overflow-auto h-full" space={1}>
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequest?.id} />
))}
{/*<Colors />*/}
enum ItemTypes {
REQUEST = 'request',
}
<HStack
className="absolute bottom-1 left-1 right-0 mx-1"
alignItems="center"
justifyContent="end"
>
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
</HStack>
export const Sidebar = memo(function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const createRequest = useCreateRequest();
const sidebarRef = useRef<HTMLDivElement>(null);
const activeRequestId = useActiveRequestId();
const activeEnvironmentId = useActiveEnvironmentId();
const unorderedRequests = useRequests();
const deleteAnyRequest = useDeleteAnyRequest();
const routes = useAppRoutes();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
// TODO: Move these listeners to a central place
useListenToTauriEvent('new_request', async () => createRequest.mutate({}));
const focusActiveRequest = useCallback(
(forcedIndex?: number) => {
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
if (index < 0) return;
setSelectedIndex(index >= 0 ? index : undefined);
setHasFocus(true);
sidebarRef.current?.focus();
},
[activeRequestId, requests],
);
const handleSelect = useCallback(
(requestId: string) => {
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (!request) return;
routes.navigate('request', {
requestId,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedIndex(index);
focusActiveRequest(index);
},
[focusActiveRequest, requests, routes, activeEnvironmentId],
);
const handleClearSelected = useCallback(() => {
setSelectedIndex(undefined);
}, [setSelectedIndex]);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest();
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback(
(e: KeyboardEvent) => {
if (!hasFocus) return;
e.preventDefault();
const selectedRequest = requests[selectedIndex ?? -1];
if (selectedRequest === undefined) return;
deleteAnyRequest.mutate(selectedRequest.id);
},
[deleteAnyRequest, hasFocus, requests, selectedIndex],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useListenToTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(selectedIndex ?? 0);
},
[focusActiveRequest, hidden, activeRequestId],
);
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const request = requests[selectedIndex ?? -1];
if (!request || request.id === activeRequestId) return;
e.preventDefault();
routes.navigate('request', {
requestId: request.id,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
});
useKey(
'ArrowUp',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? requests.length) - 1;
if (newIndex < 0) {
newIndex = requests.length - 1;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
useKey(
'ArrowDown',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? -1) + 1;
if (newIndex > requests.length - 1) {
newIndex = 0;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
return (
<div aria-hidden={hidden} className="h-full">
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classNames(
className,
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
)}
>
<SidebarItems
selectedIndex={selectedIndex}
requests={requests}
focused={hasFocus}
onSelect={handleSelect}
onClearSelected={handleClearSelected}
/>
</VStack>
</div>
);
});
interface SidebarItemsProps {
requests: HttpRequest[];
focused: boolean;
selectedIndex?: number;
onSelect: (requestId: string) => void;
onClearSelected: () => void;
}
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
const updateRequest = useUpdateRequest(request);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = async (el: HTMLInputElement) => {
await updateRequest.mutate({ name: el.value });
setEditing(false);
};
function SidebarItems({
requests,
focused,
selectedIndex,
onSelect,
onClearSelected,
}: SidebarItemsProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
const handleFocus = (el: HTMLInputElement | null) => {
el?.focus();
el?.select();
};
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
(id, side) => {
const dragIndex = requests.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[requests],
);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
onClearSelected();
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (request === undefined) return;
const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
else newRequests.splice(hoveredIndex, 0, request);
// Do a simple find because the math is too hard
const newIndex = newRequests.findIndex((r) => r.id === requestId) ?? 0;
const beforePriority = newRequests[newIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[newIndex + 1]?.sortPriority ?? 0;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
newRequests.forEach(({ id }, i) => {
const sortPriority = i * 1000;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id, update });
});
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id: requestId, update });
}
},
[hoveredIndex, requests, updateRequest, onClearSelected],
);
return (
<li>
<Button
color="custom"
size="sm"
className={classnames(
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 hover:text-gray-800 active:bg-gray-200/30',
<>
{requests.map((r, i) => (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem
key={r.id}
selected={selectedIndex === i}
requestId={r.id}
requestName={r.name}
onMove={handleMove}
onEnd={handleEnd}
useProminentStyles={focused}
onSelect={onSelect}
/>
</Fragment>
))}
{hoveredIndex === requests.length && <DropMarker />}
</>
);
}
type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
useProminentStyles?: boolean;
selected?: boolean;
onSelect: (requestId: string) => void;
draggable?: boolean;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const latestResponse = useLatestResponse(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === requestId;
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
updateRequest.mutate((r) => ({ ...r, name: el.value }));
setEditing(false);
},
[updateRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
el?.focus();
el?.select();
}, []);
const handleInputKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleSelect = useCallback(() => {
onSelect(requestId);
}, [onSelect, requestId]);
return (
<li ref={ref} className={classNames(className, 'block group/item px-2 pb-0.5')}>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlight text-gray-800',
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
)}
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
}}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
onDoubleClick={() => setEditing(true)}
justify="start"
>
{editing ? (
<input
ref={handleFocus}
defaultValue={request.name}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
}}
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className="truncate">{request.name || request.url}</span>
<span className={classNames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
</li>
);
}
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
};
type DragItem = {
id: string;
requestName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
onMove,
onEnd,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
}),
[onEnd],
);
connectDrag(ref);
connectDrop(ref);
return (
<SidebarItem
ref={ref}
draggable
className={classNames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
{...props}
/>
);
});

View File

@@ -0,0 +1,30 @@
import { memo } from 'react';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const { hidden, toggle } = useSidebarHidden();
return (
<HStack>
{hidden && (
<IconButton
onClick={toggle}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
)}
<IconButton
size="sm"
icon="plusCircle"
title="Create Request"
onClick={() => createRequest.mutate({})}
/>
</HStack>
);
});

View File

@@ -1,67 +1,87 @@
import { Button } from './core/Button';
import { DropdownMenuRadio } from './core/Dropdown';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { RequestMethodDropdown } from './RequestMethodDropdown';
interface Props {
sendRequest: () => void;
loading: boolean;
method: string;
url: string;
onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void;
}
type Props = Pick<HttpRequest, 'id' | 'url' | 'method'> & {
className?: string;
};
export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) {
const inputRef = useRef<EditorView>(null);
const sendRequest = useSendRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
[updateRequest],
);
const handleUrlChange = useCallback(
(url: string) => updateRequest.mutate({ url }),
[updateRequest],
);
const loading = useIsResponseLoading(requestId);
const { updateKey } = useRequestUpdateKey(requestId);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
},
[sendRequest],
);
useListenToTauriEvent('focus_url', () => {
inputRef.current?.focus();
});
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
sendRequest();
}}
className="w-full flex items-center"
>
<form onSubmit={handleSubmit} className={classNames('url-bar', className)}>
<Input
autocompleteVariables
ref={inputRef}
size={isFocused ? 'auto' : 'sm'}
hideLabel
useEditor={{ useTemplating: true, contentType: 'url' }}
className="px-0"
useTemplating
contentType="url"
className="px-0 py-0.5"
name="url"
label="Enter URL"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
onChange={onUrlChange}
forceUpdateKey={updateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
placeholder="Enter a URL..."
placeholder="https://example.com"
leftSlot={
<DropdownMenuRadio
onValueChange={(v) => onMethodChange(v.value)}
value={method.toUpperCase()}
items={[
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'POST', value: 'POST' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'OPTIONS', value: 'OPTIONS' },
{ label: 'HEAD', value: 'HEAD' },
]}
>
<Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start">
{method.toUpperCase()}
</Button>
</DropdownMenuRadio>
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="!h-auto mx-0.5 my-0.5"
/>
}
rightSlot={
<IconButton
size="xs"
iconSize="sm"
title="Send Request"
type="submit"
className="mr-0.5"
size="sm"
className="!h-auto w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
title="Send Request"
/>
}
/>
</form>
);
}
});

View File

@@ -1,40 +1,184 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
HTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
import { Sidebar } from './Sidebar';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { Overlay } from './Overlay';
import { RequestResponse } from './RequestResponse';
import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { WorkspaceHeader } from './WorkspaceHeader';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
const activeRequest = useActiveRequest();
const { width } = useWindowSize();
const isH = width > 900;
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useListenToTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide) setFloating(true);
else if (!shouldHide) setFloating(false);
}, [windowSize.width]);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (width === undefined) return;
unsub();
const mouseStartX = e.clientX;
const startWidth = width;
moveState.current = {
move: async (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 100) {
hide();
resetWidth();
} else {
show();
setWidth(newWidth);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[setWidth, resetWidth, width, hide, show],
);
const sideWidth = hidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: floating
? `
' ${head.gridArea}' auto
' ${body.gridArea}' minmax(0,1fr)
/ 1fr`
: `
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth, floating],
);
if (windowSize.width <= 100) {
return (
<div>
<Button>Send</Button>
</div>
);
}
return (
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
<Sidebar />
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
alignItems="center"
>
{activeRequest?.name}
</HStack>
<div
className={classnames(
'grid',
isH
? 'grid-cols-[1fr_1fr] grid-rows-1'
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2 pb-0')} />
<ResponsePane />
</div>
</div>
<div
style={styles}
className={classNames(
'grid w-full h-full',
// Animate sidebar width changes but only when not resizing
// because it's too slow to animate on mouse move
!isResizing && 'transition-all',
)}
>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide} zIndex={10}>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize className="border-transparent">
<HStack space={0.5}>
<SidebarActions />
</HStack>
</HeaderSize>
<Sidebar />
</motion.div>
</Overlay>
) : (
<>
<div style={side} className={classNames('overflow-hidden bg-gray-100')}>
<Sidebar className="border-r border-highlight" />
</div>
<ResizeHandle
className="-translate-x-3"
justify="end"
side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart}
onReset={resetWidth}
/>
</>
)}
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<RequestResponse style={body} />
</div>
);
}
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
function HeaderSize({ className, ...props }: HeaderSizeProps) {
const platform = useOsInfo();
return (
<div
className={classNames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
platform?.osType === 'Darwin' && 'pl-20',
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,168 @@
import { invoke } from '@tauri-apps/api';
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { ButtonProps } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown'>;
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
className,
...buttonProps
}: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
key: w.id,
label: w.name,
rightSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : undefined,
onSelect: async () => {
dialog.show({
id: 'open-workspace',
size: 'sm',
title: 'Open Workspace',
description: (
<>
Where would you like to open <InlineCode>{w.name}</InlineCode>?
</>
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" className="mt-6">
<Button
className="focus"
color="gray"
rightSlot={<Icon icon="openNewWindow" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
await invoke('new_window', {
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
});
}}
>
New Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
routes.navigate('workspace', { workspaceId: w.id, environmentId });
}}
>
This Window
</Button>
</HStack>
);
},
});
},
}));
const activeWorkspaceItems: DropdownItem[] =
workspaces.length <= 1
? []
: [
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
return [
...activeWorkspaceItems,
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Workspace',
description: (
<>
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeWorkspace?.name,
});
updateWorkspace.mutate({ name });
},
},
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteWorkspace.mutate,
variant: 'danger',
},
{ type: 'separator' },
{
key: 'create-workspace',
label: 'Create Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: '',
description: 'Enter a name for the new workspace',
title: 'Create Workspace',
});
createWorkspace.mutate({ name });
},
},
];
}, [
activeWorkspace?.name,
activeWorkspaceId,
createWorkspace,
deleteWorkspace.mutate,
dialog,
prompt,
routes,
updateWorkspace,
workspaces,
]);
return (
<Dropdown items={items}>
<Button
forDropdown
size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')}
{...buttonProps}
>
{activeWorkspace?.name}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,47 @@
import classNames from 'classnames';
import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
className?: string;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
return (
<HStack
justifyContent="center"
alignItems="center"
className={classNames(className, 'w-full h-full')}
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceActionsDropdown />
<EnvironmentActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (
<RequestActionsDropdown requestId={activeRequest?.id}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
)}
</div>
</HStack>
);
});

View File

@@ -1,18 +1,25 @@
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
import { Navigate } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
export default function Workspaces() {
const routes = useAppRoutes();
const recentWorkspaceIds = useRecentWorkspaces();
const workspaces = useWorkspaces();
return (
<VStack as="ul" className="p-12">
<Heading>Workspaces</Heading>
{workspaces.map((w) => (
<Button key={w.id} color="gray" to={`/workspaces/${w.id}`}>
{w.name}
</Button>
))}
</VStack>
);
const loading = workspaces.length === 0 && recentWorkspaceIds.length === 0;
if (loading) {
return null;
}
const workspaceId = recentWorkspaceIds[0] ?? workspaces[0]?.id ?? null;
if (workspaceId === null) {
return <Heading>There are no workspaces</Heading>;
}
// TODO: Somehow get recent environmentId for the workspace in here too
return <Navigate to={routes.paths.workspace({ workspaceId })} />;
}

View File

@@ -0,0 +1,21 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function Banner({ children, className }: Props) {
return (
<div>
<div
className={classNames(
className,
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
)}
>
{children}
</div>
</div>
);
}

View File

@@ -1,84 +1,82 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
custom: 'ring-blue-500/50',
default:
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
innerClassName?: string;
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
disabled?: boolean;
title?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Button = forwardRef<any, ButtonProps>(function Button(
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
to,
isLoading,
className,
innerClassName,
children,
forDropdown,
color,
type = 'button',
justify = 'center',
size = 'md',
leftSlot,
rightSlot,
disabled,
...props
}: ButtonProps,
ref,
) {
if (typeof to === 'string') {
return (
<Link
ref={ref}
to={to}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</button>
);
}
const classes = useMemo(
() =>
classNames(
className,
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
),
[className, disabled, color, justify, size],
);
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{isLoading ? (
<Icon icon="update" size={size} className="animate-spin mr-1" />
) : leftSlot ? (
<div className="mr-1">{leftSlot}</div>
) : null}
<div className={classNames('max-w-[15em] truncate w-full text-left', innerClassName)}>{children}</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
});
export const Button = memo(_Button);

View File

@@ -0,0 +1,40 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import { Icon } from './Icon';
interface Props {
checked: boolean;
title: string;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
const handleClick = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
return (
<button
role="checkbox"
aria-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={handleClick}
title={title}
className={classNames(
className,
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
'focus:border-focus',
'disabled:opacity-disabled',
checked && 'bg-gray-200/10',
// Remove focus style
'outline-none',
)}
>
<div className="flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
</div>
</button>
);
}

View File

@@ -0,0 +1,21 @@
import classNames from 'classnames';
interface Props {
count: number;
className?: string;
}
export function CountBadge({ count, className }: Props) {
if (count === 0) return null;
return (
<div
aria-hidden
className={classNames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}
</div>
);
}

View File

@@ -1,58 +1,92 @@
import * as D from '@radix-ui/react-dialog';
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useKeyPressEvent } from 'react-use';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
export interface DialogProps {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
onClose: () => void;
title?: ReactNode;
description?: ReactNode;
className?: string;
wide?: boolean;
size?: 'sm' | 'md' | 'full' | 'dynamic';
hideX?: boolean;
}
export function Dialog({
children,
className,
wide,
size = 'full',
open,
onOpenChange,
onClose,
title,
description,
}: Props) {
hideX,
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
return (
<D.Root open={open} onOpenChange={onOpenChange}>
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
<D.Content>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack alignItems="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
</div>
</D.Content>
</motion.div>
</D.Portal>
</D.Root>
<Overlay open={open} onClose={onClose} portalName="dialog">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
role="dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="pointer-events-auto"
>
<motion.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
'gap-2 grid grid-rows-[auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'p-5 rounded-lg overflow-auto',
'dark:border border-highlight shadow shadow-black/10',
'max-w-[90vw] max-h-[calc(100vh-8em)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
{title ? (
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description && <p id={descriptionId}>{description}</p>}
<div className="h-full w-full">{children}</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
<IconButton
onClick={onClose}
title="Close dialog"
aria-label="Close"
icon="x"
size="sm"
className="ml-auto absolute right-1 top-1"
/>
)}
</motion.div>
</div>
</div>
</Overlay>
);
}

View File

@@ -1,23 +0,0 @@
import * as Separator from '@radix-ui/react-separator';
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
className?: string;
}
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
return (
<Separator.Root
className={classnames(
className,
'bg-gray-50',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
orientation={orientation}
decorative={decorative}
/>
);
}

View File

@@ -1,306 +1,381 @@
import * as D from '@radix-ui/react-dropdown-menu';
import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode, ForwardedRef } from 'react';
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import React, {
Children,
cloneElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import { Button } from './Button';
import { Separator } from './Separator';
import { VStack } from './Stacks';
import { Overlay } from '../Overlay';
interface DropdownMenuRadioProps {
children: ReactNode;
onValueChange: ((v: { label: string; value: string }) => void) | null;
value: string;
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
items: {
label: string;
value: string;
}[];
}
export function DropdownMenuRadio({
children,
items,
onValueChange,
label,
value,
}: DropdownMenuRadioProps) {
const handleChange = (value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
}
};
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
{items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label}
</DropdownMenuRadioItem>
))}
</D.DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
export interface DropdownProps {
children: ReactNode;
items: (
| {
label: string;
onSelect?: () => void;
disabled?: boolean;
leftSlot?: ReactNode;
}
| '-----'
)[];
}
export function Dropdown({ children, items }: DropdownProps) {
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{items.map((item, i) => {
if (item === '-----') {
return <DropdownMenuSeparator key={i} />;
} else {
return (
<DropdownMenuItem
key={i}
onSelect={() => item.onSelect?.()}
disabled={item.disabled}
leftSlot={item.leftSlot}
>
{item.label}
</DropdownMenuItem>
);
}
})}
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
interface DropdownMenuPortalProps {
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
const container = document.querySelector<Element>('#radix-portal');
if (container === null) return null;
return (
<D.Portal>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
</D.Portal>
);
}
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
function DropdownMenuContent(
{ className, children, ...props }: D.DropdownMenuContentProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const [styles, setStyles] = useState<{ maxHeight: number }>();
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
const initDivRef = (ref: HTMLDivElement | null) => {
setDivRef(ref);
};
// Calculate the max height so we can scroll
useLayoutEffect(() => {
if (divRef === null) return;
// Needs to be in a setTimeout because the ref is not positioned yet
// TODO: Make this better?
setTimeout(() => {
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = divRef.getBoundingClientRect();
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
setStyles(styles);
});
}, [divRef]);
return (
<D.Content
ref={initDivRef}
align="start"
className={classnames(
className,
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
'overflow-auto m-1',
)}
style={styles}
{...props}
>
{children}
</D.Content>
);
},
);
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
function DropdownMenuItem({
leftSlot,
rightSlot,
className,
children,
disabled,
...props
}: DropdownMenuItemProps) {
return (
<D.Item
asChild
disabled={disabled}
className={classnames(className, disabled && 'opacity-30')}
{...props}
>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</D.Item>
);
}
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
//
// function DropdownMenuSubTrigger({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuSubTriggerProps) {
// return (
// <DropdownMenu.SubTrigger asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.SubTrigger>
// );
// }
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
return (
<D.RadioItem asChild {...props}>
<ItemInner
leftSlot={
<D.ItemIndicator>
<CheckIcon />
</D.ItemIndicator>
}
rightSlot={rightSlot}
>
{children}
</ItemInner>
</D.RadioItem>
);
}
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
// ref,
// ) {
// return (
// <DropdownMenu.SubContent
// ref={ref}
// alignOffset={0}
// sideOffset={4}
// className={classnames(className, dropdownMenuClasses)}
// {...props}
// />
// );
// },
// );
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
return (
<D.Label asChild {...props}>
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
{children}
</ItemInner>
</D.Label>
);
}
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
return (
<D.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
children: ReactNode;
className?: string;
};
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
);
export type DropdownItem =
| {
key: string;
type?: 'default';
label: ReactNode;
variant?: 'danger';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
onSelect?: () => void;
}
| DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
}
interface ItemInnerProps {
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
className?: string;
export interface DropdownRef {
isOpen: boolean;
open: (activeIndex?: number) => void;
toggle: (activeIndex?: number) => void;
close?: () => void;
next?: () => void;
prev?: () => void;
select?: () => void;
}
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
toggle(activeIndex?: number) {
if (!open) this.open(activeIndex);
else setOpen(false);
},
open(activeIndex?: number) {
if (activeIndex === undefined) {
setDefaultSelectedIndex(undefined);
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
},
}));
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref: buttonRef,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
}),
};
return cloneElement(existingChild, props);
}, [children]);
const handleClose = useCallback(() => {
setOpen(false);
buttonRef.current?.focus();
}, []);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
const windowSize = useWindowSize();
const triggerRect = useMemo(() => {
windowSize; // Make TS happy with this dep
if (!open) return null;
return buttonRef.current?.getBoundingClientRect();
}, [open, windowSize]);
return (
<div
ref={ref}
className={classnames(
<>
{child}
{open && triggerRect && (
<Menu
ref={menuRef}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerRect={triggerRect}
onClose={handleClose}
/>
)}
</>
);
});
interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
// Calculate the max height so we can scroll
const initMenu = useCallback((el: HTMLDivElement | null) => {
if (el === null) return {};
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = el.getBoundingClientRect();
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []);
// Close menu on space bar
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
e.preventDefault();
onClose();
}
},
[onClose],
);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
nextIndex--;
} else if (nextIndex < 0) {
nextIndex = items.length - 1;
} else {
break;
}
}
return nextIndex;
});
}, [items]);
const handleNext = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
nextIndex++;
} else if (nextIndex >= items.length) {
nextIndex = 0;
} else {
break;
}
}
return nextIndex;
});
}, [items]);
useKey('ArrowUp', (e) => {
e.preventDefault();
handlePrev();
});
useKey('ArrowDown', (e) => {
e.preventDefault();
handleNext();
});
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
useImperativeHandle(
ref,
() => ({
close: onClose,
prev: handlePrev,
next: handleNext,
select: () => {
const item = items[selectedIndex ?? -1] ?? null;
if (!item) return;
handleSelect(item);
},
}),
[handleNext, handlePrev, handleSelect, items, onClose, selectedIndex],
);
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties;
}>(() => {
const docWidth = document.documentElement.getBoundingClientRect().width;
const spaceRemaining = docWidth - triggerRect.left;
const top = triggerRect?.bottom + 5;
const onRight = spaceRemaining < 200;
const containerStyles = onRight
? { top, right: docWidth - triggerRect?.right }
: { top, left: triggerRect?.left };
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size }
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles };
}, [triggerRect]);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
setSelectedIndex(index);
},
[items],
);
if (items.length === 0) return null;
return (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
);
});
interface MenuItemProps {
className?: string;
item: DropdownItem;
onSelect: (item: DropdownItem) => void;
onFocus: (item: DropdownItem) => void;
focused: boolean;
}
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
if (el === null) return;
if (focused) {
setTimeout(() => el.focus(), 0);
}
},
[focused],
);
if (item.type === 'separator') return <Separator className="my-1.5" />;
return (
<Button
ref={initRef}
size="xs"
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
onFocus={handleFocus}
onClick={handleClick}
justify="start"
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
rightSlot={item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
className={classNames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)}
{...props}
>
{leftSlot && <div className="w-6">{leftSlot}</div>}
<div>{children}</div>
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
</div>
<div
className={classNames(
// Add padding on right when no right slot, for some visual balance
!item.rightSlot && 'pr-4',
)}
>
{item.label}
</div>
</Button>
);
});
}

View File

@@ -0,0 +1,25 @@
interface Props {
millis: number;
}
export function DurationTag({ millis }: Props) {
let num;
let unit;
if (millis > 1000 * 60) {
num = millis / 1000 / 60;
unit = 'min';
} else if (millis > 1000) {
num = millis / 1000;
unit = 's';
} else {
num = millis;
unit = 'ms';
}
return (
<span title={`${millis} milliseconds`}>
{Math.round(num * 10) / 10} {unit}
</span>
);
}

View File

@@ -1,166 +1,216 @@
.cm-wrapper {
@apply h-full overflow-hidden;
@apply h-full overflow-hidden;
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
}
&.cm-focused {
outline: none !important;
}
.cm-content {
@apply py-0;
}
.cm-line {
@apply text-gray-800 caret-gray-800 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-selection;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40;
/* Bring above on hover */
@apply hover:z-10 relative;
-webkit-text-security: none;
&.placeholder-widget-error {
@apply bg-red-300/40 border-red-300 border-opacity-40;
}
}
}
&.cm-singleline {
.cm-editor {
@apply w-full h-auto;
}
.cm-scroller {
@apply font-mono text-[0.8rem] overflow-hidden;
}
.cm-line {
@apply px-2 overflow-hidden;
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
}
&.cm-focused {
outline: none !important;
}
.cm-line {
@apply text-gray-900 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-gray-400;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/60;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
/* Bring above on hover */
@apply hover:z-10 relative;
}
@apply h-full;
}
&.cm-singleline {
.cm-editor {
@apply h-full w-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
.cm-scroller {
@apply font-mono flex text-[0.8rem];
align-items: center !important;
overflow: hidden !important;
}
.cm-line {
@apply px-0;
}
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply h-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
}
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
}
.cm-editor .cm-gutterElement {
transition: color var(--transition-duration);
@apply flex items-center;
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@apply block w-1.5 h-1.5 border-transparent -rotate-45
@apply block w-1.5 h-1.5 border-transparent -rotate-45
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.4em] pl-[0.3em];
@apply pt-[0.38em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@apply rotate-[-135deg];
@apply rotate-[-135deg];
}
.cm-editor .fold-gutter-icon:hover {
@apply text-gray-900 bg-gray-300/50;
@apply text-gray-900 bg-gray-300/50;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
}
.cm-editor .cm-activeLineGutter {
@apply bg-transparent;
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-800;
}
&.cm-focused .cm-activeLineGutter {
@apply text-gray-600;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
}
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
.cm-content {
@apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field.
* We can make this dynamic if we need it to be configurable later
*/
&.cm-lineWrapping {
@apply break-all;
}
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
* {
@apply transition-none;
&.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-gray-100 text-gray-900;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-highlight text-gray-900;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5 mr-2 flex-shrink-0;
}
.cm-completionLabel {
@apply text-gray-700;
}
.cm-completionDetail {
@apply ml-auto pl-6;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -1,131 +1,255 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState } from '@codemirror/state';
import { Compartment, EditorState, Transaction } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useRef } from 'react';
import { useDebounce, useUnmount } from 'react-use';
import { debounce } from '../../../lib/debounce';
import type { MutableRefObject, ReactNode } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExt } from './singleLine';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
export interface _EditorProps {
// Export some things so all the code-split parts are in this file
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
export { graphql } from 'cm6-graphql';
export { formatSdl } from 'format-graphql';
export interface EditorProps {
id?: string;
readOnly?: boolean;
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
contentType?: string | null;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
autoSelect?: boolean;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
singleLine?: boolean;
wrapLines?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
actions?: ReactNode;
}
export function _Editor({
readOnly,
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
onChange,
className,
singleLine,
}: _EditorProps) {
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
heightMode,
contentType,
autoFocus,
autoSelect,
placeholder,
useTemplating,
defaultValue,
forceUpdateKey,
onChange,
onFocus,
onBlur,
onKeyDown,
className,
singleLine,
format,
autocomplete,
autocompleteVariables,
actions,
wrapLines,
}: EditorProps,
ref,
) {
const e = useActiveEnvironment();
const environment = autocompleteVariables ? e : null;
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps['onKeyDown']>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(() => {
if (cm.current === null) return;
const effect = placeholderCompartment.current.reconfigure(
placeholderExt(placeholderElFromText(placeholder ?? '')),
);
cm.current?.view.dispatch({ effects: effect });
}, [placeholder]);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(() => {
if (cm.current === null) return;
const ext = wrapLines ? [EditorView.lineWrapping] : [];
const effect = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects: effect });
}, [wrapLines]);
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, langHolder } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating });
view.dispatch({ effects: langHolder.reconfigure(ext) });
}, [contentType]);
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, environment, useTemplating, autocomplete });
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment]);
// Initialize the editor
const initDivRef = (el: HTMLDivElement | null) => {
if (el === null || cm.current !== null) return;
useEffect(() => {
if (cm.current === null) return;
const { view } = cm.current;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue ?? '' } });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
// Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
return;
}
let view: EditorView;
try {
const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
contentType,
useTemplating,
autocomplete,
environment,
});
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
langHolder.of(langExt),
languageCompartment.of(langExt),
placeholderCompartment.current.of([]),
wrapLinesCompartment.current.of([]),
...getExtensions({
container: el,
container,
readOnly,
placeholder,
singleLine,
onChange,
contentType,
useTemplating,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
],
});
const view = new EditorView({ state, parent: el });
cm.current = { view, langHolder };
syncGutterBg({ parent: el, className });
if (autoFocus) view.focus();
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className });
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
};
return (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cmContainer = (
<div
ref={initDivRef}
className={classnames(
ref={initEditorRef}
className={classNames(
className,
'cm-wrapper text-base bg-gray-50',
type === 'password' && 'cm-obscure-text',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly',
)}
>
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
/>
);
if (singleLine) {
return cmContainer;
}
return (
<div className="group relative h-full w-full">
{cmContainer}
{format && (
<HStack space={0.5} alignItems="center" className="absolute bottom-2 right-0 ">
{actions}
<IconButton
showConfirm
size="sm"
title="Reformat contents"
icon="magicWand"
className="transition-opacity opacity-0 group-hover:opacity-70"
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const insert = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
cm.current.view.contentDOM.blur();
}}
/>
</HStack>
)}
</div>
);
}
});
export const Editor = memo(_Editor);
function getExtensions({
container,
readOnly,
singleLine,
placeholder,
onChange,
contentType,
useTemplating,
}: Pick<
_EditorProps,
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating });
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
onKeyDown: MutableRefObject<EditorProps['onKeyDown']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
@@ -138,37 +262,41 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(ext ? [ext] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(placeholder ? [placeholderExt(placeholder)] : []),
...(singleLine
? [
EditorView.domEventHandlers({
focus: (e, view) => {
// select all text on focus, like a regular input does
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
keydown: (e) => {
// Submit nearest form on enter if there is one
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
},
}),
]
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// Handle onFocus
// NOTE: These *must* be anonymous functions so the references update properly
EditorView.domEventHandlers({
focus: () => onFocus.current?.(),
blur: () => onBlur.current?.(),
keydown: e => onKeyDown.current?.(e),
}),
// Handle onChange
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function' && update.docChanged) {
onChange(update.state.doc.toString());
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
onChange.current?.(update.state.doc.toString());
}
}),
];
}
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
// Make sure document has changed, ensuring user events like selections don't count.
if (viewUpdate.docChanged) {
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
for (const transaction of viewUpdate.transactions) {
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
const userEventType = transaction.annotation(Transaction.userEvent);
if (userEventType) return userEventType;
}
}
return false;
}
const syncGutterBg = ({
parent,
className = '',
@@ -186,3 +314,9 @@ const syncGutterBg = ({
gutterEl?.classList.add(...bgClasses);
}
};
const placeholderElFromText = (text: string) => {
const el = document.createElement('div');
el.innerHTML = text.replace('\n', '<br/>');
return el;
};

View File

@@ -5,7 +5,6 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
@@ -32,24 +31,30 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { debouncedAutocompletionDisplay } from './autocomplete';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
import { url } from './url/extension';
import type { Environment } from '../../../lib/models';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: '#757b93',
color: 'hsl(var(--color-gray-600))',
fontStyle: 'italic',
},
{
tag: [t.paren],
color: 'hsl(var(--color-gray-900))',
},
{
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'hsl(var(--color-blue-600))',
},
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
@@ -78,10 +83,10 @@ export const myHighlightStyle = HighlightStyle.define([
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql+json': graphqlLanguageSupport(),
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
'text/html': xml(), // HTML as xml because HTML is oddly slow
'application/xml': xml(),
'text/xml': xml(),
url: url(),
@@ -89,30 +94,41 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({
contentType,
useTemplating,
}: {
contentType?: string;
useTemplating?: boolean;
}) {
useTemplating = false,
environment,
autocomplete,
}: { environment: Environment | null } & Pick<
EditorProps,
'contentType' | 'useTemplating' | 'autocomplete'
>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json();
if (justContentType === 'application/graphql') {
return graphql();
}
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
return [base];
return base;
}
return twig(base);
return twig(base, environment, autocomplete);
}
export const baseExtensions = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];
@@ -131,6 +147,7 @@ export const multiLineExtensions = [
},
}),
EditorState.allowMultipleSelections.of(true),
drawSelection(),
indentOnInput(),
closeBrackets(),
rectangularSelection(),

View File

@@ -0,0 +1,35 @@
import type { CompletionContext } from '@codemirror/autocomplete';
export interface GenericCompletionOption {
label: string;
type: 'constant' | 'variable';
/** When given, should be a number from -99 to 99 that adjusts
* how this completion is ranked compared to other completions
* that match the input as well as this one. A negative number
* moves it down the list, a positive number moves it up. */
boost?: number;
}
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletionOption[];
}
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/\w*/);
// Only match if we're at the start of the line
if (toMatch === null || toMatch.from > 0) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return {
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: optionsWithoutExactMatches,
};
};
}

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