Compare commits

...

234 Commits

Author SHA1 Message Date
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
Gregory Schier
bdf89ac288 Fix platform check 2023-03-14 00:15:01 -07:00
Gregory Schier
debd3c8185 Some small changes 2023-03-14 00:08:03 -07:00
Gregory Schier
f81a3ae8e7 Move stuff around 2023-03-13 23:30:14 -07:00
Gregory Schier
7d4e9894c3 Refactor hooks to be easier to use 2023-03-13 23:25:41 -07:00
Gregory Schier
4bf22d8a60 Fix header editor and scroll in general 2023-03-13 19:37:36 -07:00
Gregory Schier
8be4971a23 Lazy load routes 2023-03-13 13:56:13 -07:00
Gregory Schier
359e916b73 Back to React 2023-03-13 09:50:49 -07:00
Gregory Schier
68058f3e41 Move some stuff around 2023-03-13 09:24:38 -07:00
Gregory Schier
0c6fa3e634 Fix URL bar 2023-03-13 00:13:25 -07:00
Gregory Schier
0fa25c6335 Fix ButtonLink and edit request names 2023-03-13 00:11:23 -07:00
Gregory Schier
5684479f1d Remove old rust cache action 2023-03-12 22:48:43 -07:00
Gregory Schier
2d1603601c Better rust cache 2023-03-12 22:47:43 -07:00
Gregory Schier
f5394b2210 Start GraphQL support 2023-03-12 22:43:25 -07:00
Gregory Schier
833db5df06 Fix artifact tag 2023-03-12 21:41:15 -07:00
Gregory Schier
525ac7e980 Remove wasm stuff 2023-03-12 21:25:31 -07:00
Gregory Schier
44a747c80a Use tauri action 2023-03-12 21:13:08 -07:00
Gregory Schier
2056e7f40a Fix traffic lights thingy 2023-03-12 20:47:52 -07:00
Gregory Schier
9b6c1ad364 Cache cargo bin for "install" 2023-03-12 19:10:39 -07:00
Gregory Schier
34987bcacb Refformat 2023-03-12 19:03:27 -07:00
Gregory Schier
b62c11222a Fix artifact upload 2023-03-12 19:01:48 -07:00
Gregory Schier
b3cee3ace3 Fix dev 2023-03-12 18:39:02 -07:00
Gregory Schier
222c054c95 Split out macos deps 2023-03-12 18:36:25 -07:00
Gregory Schier
46f18a2491 Cache workflow 2023-03-12 18:28:14 -07:00
Gregory Schier
f2ca8e2753 Add wasm-pack 2023-03-12 18:19:20 -07:00
Gregory Schier
b0d243c378 Install rsw 2023-03-12 18:14:38 -07:00
Gregory Schier
6161fb86c8 Fix artifact names 2023-03-12 18:13:00 -07:00
Gregory Schier
b09cc91fe5 Fix build command 2023-03-12 18:11:24 -07:00
Gregory Schier
ef1638cbb3 Update secrets context 2023-03-12 18:07:57 -07:00
Gregory Schier
00ef8743f2 Update workflow name 2023-03-12 18:05:45 -07:00
Gregory Schier
68222659e3 Fix workflow 2023-03-12 18:05:13 -07:00
Gregory Schier
69420a4bba Start of auto updates 2023-03-12 18:04:11 -07:00
Gregory Schier
0161bbaeb1 Fix tabbing to tabs 2023-03-11 23:32:39 -08:00
Gregory Schier
948dbfe3cc Fix eslint errors 2023-03-11 23:29:25 -08:00
Gregory Schier
338ba8b189 Got tab content scrolling working 2023-03-11 22:36:13 -08:00
Gregory Schier
ca4655b441 Removed some debug stuff 2023-03-10 10:43:15 -08:00
Gregory Schier
bf37499428 Refactor editor to update better 2023-03-10 10:39:23 -08:00
Gregory Schier
0b94b57e2a Fix headers persistence and better sending 2023-03-09 13:38:17 -08:00
Gregory Schier
fc40aead98 Hook up header editor! 2023-03-09 13:07:13 -08:00
Gregory Schier
7d7f934e6a Fix 2023-03-09 10:58:27 -08:00
Gregory Schier
d5fbf4d622 Fix blur de-select speed 2023-03-09 10:57:34 -08:00
Gregory Schier
e4f6c919dc Fix Codemirror performance!! 2023-03-09 10:50:55 -08:00
Gregory Schier
4d806ff2b1 Switch to Preact!!! 2023-03-09 00:47:25 -08:00
Gregory Schier
bf8f12274f Move some things around 2023-03-08 23:20:15 -08:00
Gregory Schier
f4f438d9fe Better scrollbar color 2023-03-08 19:23:24 -08:00
Gregory Schier
2434f373be Zoom, better sizes, color picker, sidebar footer 2023-03-08 19:22:04 -08:00
Gregory Schier
2bb2061f97 Read-only editor 2023-03-08 16:53:13 -08:00
Gregory Schier
2c011a5c2a More theme tweaks 2023-03-08 16:37:20 -08:00
Gregory Schier
f66b0ccea1 Debounce autocomplete 2023-03-08 11:25:20 -08:00
Gregory Schier
665dd8447d Minor theme updates again 2023-03-08 09:43:35 -08:00
Gregory Schier
1b61ce31e6 Editor tweaks 2023-03-07 23:05:33 -08:00
Gregory Schier
ef4d960698 Remove unneeded space 2023-03-07 22:58:13 -08:00
Gregory Schier
b6d557b632 Fix small view 2023-03-07 22:55:51 -08:00
Gregory Schier
b700bd356c Minor style tweaks 2023-03-07 22:21:58 -08:00
Gregory Schier
620dd7d3ef Lots more theme stuff 2023-03-07 21:52:21 -08:00
Gregory Schier
6575121902 Start of themes 2023-03-07 11:24:38 -08:00
Gregory Schier
7c1755a0dc More subtle layout tweaks 2023-03-06 08:57:57 -08:00
Gregory Schier
8ad301a666 More layout fiddling and error page 2023-03-04 22:26:00 -08:00
Gregory Schier
ae24cd4939 More work on the layout 2023-03-04 21:51:17 -08:00
Gregory Schier
7152e1845e Try new layout and a bunch of editor fixes 2023-03-04 19:06:12 -08:00
Gregory Schier
96c1dd4081 Fix autocomplete inside dialog 2023-03-03 17:03:20 -08:00
Gregory Schier
87c7b3a663 Beginnings of Header Editor 2023-03-03 13:18:57 -08:00
Gregory Schier
c1be46a539 Fix tailwind dark selector 2023-03-03 07:54:19 -08:00
Gregory Schier
4655e0018b Fix content type in URL 2023-03-02 23:17:09 -08:00
Gregory Schier
da5ba2e3be Add Dialog component 2023-03-02 18:46:10 -08:00
Gregory Schier
aaf95f565f More colors 2023-03-02 17:56:53 -08:00
Gregory Schier
f32b984e77 Minor style tweaks 2023-03-02 16:16:41 -08:00
Gregory Schier
548aa4c7cd Improved autocompletion! 2023-03-02 11:14:51 -08:00
Gregory Schier
0ccceaac77 Rename, fix autocomplete and singleline, etc... 2023-03-02 10:42:43 -08:00
Gregory Schier
70f534f1d8 Editor placeholder 2023-03-01 14:22:10 -08:00
Gregory Schier
61fe95b300 Some minor bugs 2023-03-01 14:16:02 -08:00
Gregory Schier
915e0e8613 Fix migrations for build and iframe rendering 2023-03-01 10:31:50 -08:00
Gregory Schier
aace2580da Tweaks 2023-03-01 10:19:21 -08:00
Gregory Schier
3d36905664 Response streaming 2023-03-01 09:05:00 -08:00
Gregory Schier
0d671423da Autocomplete, and more CM stuff! 2023-02-28 22:54:54 -08:00
Gregory Schier
aebfcb9437 Some small tweaks 2023-02-28 17:25:59 -08:00
Gregory Schier
be7ef7beb1 Better editor updating 2023-02-28 12:41:03 -08:00
Gregory Schier
d77ed0c5cc URL highlighting with inline CM 2023-02-28 11:26:26 -08:00
Gregory Schier
e57e7bcec5 Implement request deletion 2023-02-27 15:42:06 -08:00
Gregory Schier
a637842ce4 Tauri events for request model updates 2023-02-27 13:28:50 -08:00
Gregory Schier
fc54ec49af Split request upsert command 2023-02-27 10:00:57 -08:00
Gregory Schier
5c43d8510a Add toggle for pretty view 2023-02-27 09:08:48 -08:00
Gregory Schier
83f84ded8d Small tweaks 2023-02-26 15:25:55 -08:00
Gregory Schier
5658da34a2 Add variable highlighting widgets 2023-02-26 15:06:14 -08:00
Gregory Schier
38e8ef6535 Dropdown scrolling 2023-02-25 23:33:07 -08:00
Gregory Schier
8c89b06238 Show response body size 2023-02-25 23:08:19 -08:00
Gregory Schier
d85c021305 A bunch more small things 2023-02-25 23:04:31 -08:00
Gregory Schier
83bb18df03 Added react-router 2023-02-25 18:04:14 -08:00
Gregory Schier
93105a3e89 Migrations and initial data stuff 2023-02-25 16:39:18 -08:00
Gregory Schier
ba3b899115 Minor tweaks 2023-02-24 17:01:48 -08:00
Gregory Schier
fcfbc1d1da Dummy requests in sidebar 2023-02-24 16:46:56 -08:00
Gregory Schier
72486b448c Codemirror initial value support 2023-02-24 16:43:47 -08:00
Gregory Schier
7dea1b7870 Send request body 2023-02-24 16:09:19 -08:00
Gregory Schier
4de2c496c9 Vendor basicSetup 2023-02-24 14:51:56 -08:00
Gregory Schier
9e1393a392 Additional methods and tweaks 2023-02-24 14:10:25 -08:00
Gregory Schier
0901690ed6 Focus states 2023-02-24 12:35:13 -08:00
Gregory Schier
95303648cc Hook up theme and clear responses 2023-02-24 12:13:30 -08:00
Gregory Schier
1dbb08c045 SQLite store in proper dir 2023-02-22 20:18:14 -08:00
Gregory Schier
00a7d9a180 Started on grid layout 2023-02-22 19:44:44 -08:00
Gregory Schier
6c549dc086 Save responses in DB 2023-02-22 18:53:44 -08:00
Gregory Schier
dc368e326a Better URL bar 2023-02-22 16:15:25 -08:00
Gregory Schier
e42188a627 Cleaner URL bar and some improvements 2023-02-22 15:58:04 -08:00
Gregory Schier
7a6a337eff Refactor classname usage 2023-02-21 18:03:57 -08:00
Gregory Schier
3907344884 Some minor tweaks 2023-02-21 17:56:48 -08:00
198 changed files with 14044 additions and 3874 deletions

View File

@@ -1,25 +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: {
"react/react-in-jsx-scope": "off",
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": "warn",
"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"
}],
}
};

57
.github/workflows/artifacts.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
./src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
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_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: '--target universal-apple-darwin'

2
.gitignore vendored
View File

@@ -23,4 +23,4 @@ dist-ssr
*.sln
*.sw?
.rsw
*.sqlite

12
.run/Dev Desktop.run.xml Normal file
View File

@@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

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 <name>
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
```

View File

@@ -1,15 +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>Tauri + React + TS</title>
</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="radix-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>

5904
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,81 @@
{
"name": "tauri-app",
"name": "yaak-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "rsw build && tsc && vite build",
"dev": "vite",
"lint": "eslint . --ext .ts,.tsx",
"preview": "vite preview",
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\""
"tauri-dev": "YAAK_ENV=development tauri dev",
"tauri-build": "tauri build",
"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"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.6.0",
"@codemirror/search": "^6.2.3",
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@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.2.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@vitejs/plugin-react": "^3.1.0",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"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-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",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.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",
"autoprefixer": "^10.4.13",
"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",
"framer-motion": "^9.0.4",
"prettier": "^2.8.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint-plugin-react-hooks": "^4.6.0",
"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-rsw": "^2.0.11",
"vite-plugin-top-level-await": "^1.2.4"
"vite-plugin-svgr": "^2.4.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}
}

View File

@@ -1,6 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: [
require('@tailwindcss/nesting')(require('postcss-nesting')),
require('tailwindcss'),
require('autoprefixer'),
]
}

View File

@@ -1,39 +0,0 @@
name = "rsw"
version = "0.1.0"
#! time interval for file changes to trigger wasm-pack build, default `50` milliseconds
interval = 50
#! link
#! npm link @see https://docs.npmjs.com/cli/v8/commands/npm-link
#! yarn link @see https://classic.yarnpkg.com/en/docs/cli/link
#! pnpm link @see https://pnpm.io/cli/link
#! The link command will only be executed if `[[crates]] link = true`
#! cli: `npm` | `yarn` | `pnpm`, default is `npm`
cli = "npm"
#! ---------------------------
#! rsw new <name>
[new]
#! @see https://rustwasm.github.io/docs/wasm-pack/commands/new.html
#! using: `wasm-pack` | `rsw` | `user`, default is `wasm-pack`
#! 1. wasm-pack: `rsw new <name> --template <template> --mode <normal|noinstall|force>`
#! 2. rsw: `rsw new <name>`, built-in templates
#! 3. user: `rsw new <name>`, if `dir` is not configured, use `wasm-pack new <name>` to initialize the project
using = "wasm-pack"
#! this field needs to be configured when `using = "user"`
#! `using = "wasm-pack"` or `using = "rsw"`, this field will be ignored
#! copy all files in this directory
dir = "my-template"
#! ################# NPM Package #################
#! When there is only `name`, other fields will use the default configuration
#! 📦 -------- package: @rsw --------
[[crates]]
#! npm package name (path: $ROOT/@rsw)
name = "src-wasm/hello"
#! run `npm link`: `true` | `false`, default is `false`
link = true

1680
src-tauri/Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,37 @@
[package]
name = "tauri-app"
name = "yaak-app"
version = "0.0.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
description = "A network protocol testing utility app"
authors = ["Gregory Schier"]
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 = "0.2.7"
cocoa = "0.24.1"
[dependencies]
serde_json = { version = "1.0" }
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", "window-start-dragging"] }
http = { version = "0.2.8" }
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = "0.2.8"
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["full"] }
futures = { version = "0.3.26" }
deno_core = { version = "0.171.0" }
deno_ast = { version = "0.24.0", features = ["transpiling"] }
objc = { version = "0.2.7" }
cocoa = { version = "0.24.1" }
tokio = { version = "1.25.0", features = ["sync"] }
futures = "0.3.26"
deno_core = "0.178.0"
deno_ast = { version = "0.25.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = "1.3.0"
rand = "0.8.5"
chrono = { version = "0.4.23", features = ["serde"] }
base64 = "0.21.0"
[features]
# by default Tauri runs in production mode

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 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.1 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: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +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,
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
);
CREATE TABLE http_requests
(
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,
body_type TEXT
);
CREATE TABLE http_responses
(
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,
status_reason TEXT,
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL,
error TEXT
);

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

@@ -1,5 +1 @@
console.log('---------------------------');
console.log('- 👋 Hello from plugin.ts -');
console.log('---------------------------');
Deno.core.ops.op_hello('World');
Deno.core.opAsync('op_hello', 'Deno');

609
src-tauri/sqlx-data.json Normal file
View File

@@ -0,0 +1,609 @@
{
"db": "SQLite",
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
"describe": {
"columns": [
{
"name": "model",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 1,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "namespace",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 2
}
},
"query": "\n SELECT model, created_at, updated_at, namespace, key, value\n FROM key_values\n WHERE namespace = ? AND key = ?\n "
},
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
},
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
}
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
}
},
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 11
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
},
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
},
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
},
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
}
}

View File

@@ -1,102 +0,0 @@
use http::header::{HeaderName, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use reqwest::redirect::Policy;
use std::collections::HashMap;
use tauri::{AppHandle, Wry};
#[derive(serde::Serialize)]
pub struct CustomResponse {
status: String,
body: String,
url: String,
method: String,
elapsed: u128,
elapsed2: u128,
headers: HashMap<String, String>,
}
#[tauri::command]
pub async fn send_request(
app_handle: AppHandle<Wry>,
url: &str,
method: &str,
) -> Result<CustomResponse, String> {
let start = std::time::Instant::now();
let mut abs_url = url.to_string();
if !abs_url.starts_with("http://") && !abs_url.starts_with("https://") {
abs_url = format!("http://{}", url);
}
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.unwrap();
let mut headers = HeaderMap::new();
// headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
headers.insert(USER_AGENT, HeaderValue::from_static("reqwest"));
headers.insert("x-foo-bar", HeaderValue::from_static("hi mom"));
headers.insert(
HeaderName::from_static("x-api-key"),
HeaderValue::from_static("123-123-123"),
);
let m = Method::from_bytes(method.to_uppercase().as_bytes()).unwrap();
let req = client
.request(m, abs_url.to_string())
.headers(headers)
.build();
let req = match req {
Ok(v) => v,
Err(e) => {
println!("Error: {}", e);
return Err(e.to_string());
}
};
let resp = client.execute(req).await;
let elapsed = start.elapsed().as_millis();
let p = app_handle
.path_resolver()
.resolve_resource("plugins/plugin.ts")
.expect("failed to resolve resource");
crate::runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
match resp {
Ok(v) => {
let url = v.url().to_string();
let status = v.status().to_string();
let method = method.to_string();
let headers = v
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap().to_string()))
.collect::<HashMap<String, String>>();
let body = v.text().await.unwrap();
let elapsed2 = start.elapsed().as_millis();
Ok(CustomResponse {
status,
body,
elapsed,
elapsed2,
method,
url,
headers,
})
}
Err(e) => {
println!("Error: {}", e);
Err(e.to_string())
}
}
}
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}

View File

@@ -7,18 +7,499 @@
#[macro_use]
extern crate objc;
mod commands;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::create_dir_all;
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
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, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
use tokio::sync::Mutex;
use window_ext::WindowExt;
mod models;
mod runtime;
mod window_ext;
use tauri::{
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
WindowEvent,
};
use window_ext::WindowExt;
#[derive(serde::Serialize)]
pub struct CustomResponse {
status: u16,
body: String,
url: String,
method: String,
elapsed: u128,
elapsed2: u128,
headers: HashMap<String, String>,
pub status_reason: Option<&'static str>,
}
async fn migrate_db(
app_handle: AppHandle<Wry>,
db_instance: &Mutex<Pool<Sqlite>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let p = app_handle
.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 complete");
Ok(())
}
#[tauri::command]
async fn send_ephemeral_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
}
async fn actually_send_ephemeral_request(
request: models::HttpRequest,
response: &models::HttpResponse,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let mut url_string = request.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();
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
}
println!("Sending request to {}", url_string);
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
let header_name = match HeaderName::from_bytes(h.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()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
if b == "basic" {
let a = request.authentication.0;
let auth = format!(
"{}:{}",
a.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
a.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
);
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 token = request
.authentication
.0
.get("token")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
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 (request.body, request.body_type) {
(Some(b), Some(_)) => builder.body(b).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;
}
};
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 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(
v.headers()
.iter()
.map(|(k, v)| models::HttpResponseHeader {
name: k.as_str().to_string(),
value: v.to_str().unwrap().to_string(),
})
.collect(),
);
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
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,
) -> 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, "", vec![], pool)
.await
.expect("Failed to create response");
let response2 = response.clone();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
tokio::spawn(async move {
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
.await
.expect("Failed to send request");
});
emit_and_return(&window, "created_model", response)
}
async fn response_err(
response: &models::HttpResponse,
error: String,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.error = Some(error.clone());
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
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_request(
workspace_id: &str,
name: &str,
sort_priority: f64,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> 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,
None,
HashMap::new(),
None,
"",
headers,
sort_priority,
pool,
)
.await
.expect("Failed to create request");
emit_and_return(&window, "created_model", created_request)
}
#[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_request(
request: models::HttpRequest,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
let b2;
let body = match request.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
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");
emit_and_return(&window, "updated_model", updated_request)
}
#[tauri::command]
async fn delete_request(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_request(request_id, pool)
.await
.expect("Failed to delete request");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn requests(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpRequest>, String> {
let pool = &*db_instance.lock().await;
models::find_requests(workspace_id, pool)
.await
.map_err(|e| e.to_string())
}
#[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 responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
let pool = &*db_instance.lock().await;
models::find_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_response(
id: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::delete_response(id, pool)
.await
.expect("Failed to delete response");
emit_and_return(&window, "deleted_model", response)
}
#[tauri::command]
async fn delete_all_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_all_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn workspaces(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Workspace>, String> {
let pool = &*db_instance.lock().await;
let workspaces = models::find_workspaces(pool)
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
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)
}
}
#[tauri::command]
async fn delete_workspace(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(id, pool)
.await
.expect("Failed to delete workspace");
emit_and_return(&window, "deleted_model", workspace)
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
// here `"quit".to_string()` defines the menu item id, and the second parameter is the menu item label.
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);
@@ -26,39 +507,198 @@ fn main() {
tauri::Builder::default()
.system_tray(system_tray)
.setup(|app| {
let win = app.get_window("main").unwrap();
win.position_traffic_lights();
Ok(())
})
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
},
_ => {}
})
.on_window_event(|e| {
let apply_offset = || {
let win = e.window();
win.position_traffic_lights();
let dir = match is_dev() {
true => current_dir().unwrap(),
false => app.path_resolver().app_data_dir().unwrap(),
};
match e.event() {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
_ => {}
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!("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();
}
_ => {}
};
}
})
.invoke_handler(tauri::generate_handler![
commands::send_request,
commands::greet
greet,
workspaces,
get_request,
requests,
send_request,
send_ephemeral_request,
duplicate_request,
create_request,
create_workspace,
delete_workspace,
update_request,
delete_request,
responses,
get_key_value,
set_key_value,
delete_response,
delete_all_responses,
])
.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 => {
create_window(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>) -> 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_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 win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.hidden_title(true)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
})
.title_bar_style(TitleBarStyle::Overlay)
.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(),
"send_request" => win2.emit("send_request", true).unwrap(),
"refresh" => win2.eval("location.reload()").unwrap(),
"new_window" => _ = create_window(&handle2),
"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 = || {
#[cfg(target_os = "macos")]
win3.position_traffic_lights();
};
match e {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
WindowEvent::CloseRequested { .. } => {
println!("CLOSE REQUESTED");
// api.prevent_close();
}
_ => {}
}
});
#[cfg(target_os = "macos")]
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();
}

527
src-tauri/src/models.rs Normal file
View File

@@ -0,0 +1,527 @@
use std::collections::HashMap;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;
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 name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub sort_priority: f64,
pub workspace_id: String,
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>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponseHeader {
pub name: String,
pub value: String,
}
#[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 error: Option<String>,
pub url: String,
pub elapsed: i64,
pub status: i64,
pub status_reason: Option<String>,
pub body: 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, model, created_at, updated_at, name, description
FROM workspaces
"#,
)
.fetch_all(pool)
.await
}
pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description
FROM workspaces WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool)
.await
.expect("Failed to get request to delete");
let _ = sqlx::query!(
r#"
DELETE FROM workspaces
WHERE id = ?
"#,
id,
)
.execute(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");
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
VALUES (?, ?, ?)
"#,
id,
name,
description,
)
.execute(pool)
.await
.expect("Failed to insert new workspace");
get_workspace(&id, pool).await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool)
.await
.expect("Failed to get request to duplicate");
// 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.as_str()
}
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
sqlx::query!(
r#"
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,
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,
url,
method,
body,
body_type,
auth_json,
authentication_type,
headers_json,
sort_priority,
)
.execute(pool)
.await
.expect("Failed to insert new request");
get_request(id, pool).await
}
pub async fn find_requests(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpRequest>, sqlx::Error> {
sqlx::query_as!(
HttpRequest,
r#"
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 = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
sqlx::query_as!(
HttpRequest,
r#"
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 = ?
"#,
id,
)
.fetch_one(pool)
.await
}
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 _ = sqlx::query!(
r#"
DELETE FROM http_requests
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(req)
}
pub async fn create_response(
request_id: &str,
elapsed: i64,
url: &str,
status: i64,
status_reason: Option<&str>,
body: &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 headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_responses (
id,
request_id,
workspace_id,
elapsed,
url,
status,
status_reason,
body,
headers
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
req.workspace_id,
elapsed,
url,
status,
status_reason,
body,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
get_response(&id, pool).await
}
pub async fn update_response_if_id(
response: HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
}
return update_response(response, 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, body, error, headers, updated_at) =
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.url,
response.status,
response.status_reason,
response.body,
response.error,
headers_json,
response.id,
)
.execute(pool)
.await
.expect("Failed to update response");
get_response(&response.id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as_unchecked!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at,
status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn find_responses(
request_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, status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at ASC
"#,
request_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
.expect("Failed to get response to delete");
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
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;
Ok(())
}
pub fn generate_id(prefix: &str) -> String {
format!(
"{prefix}_{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
)
}

View File

@@ -1,5 +1,5 @@
(function (globalThis) {
Deno.core.initializeAsyncOps();
// Deno.core.print(Object.keys(Deno.core).join('\n'));
function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(' ');

View File

@@ -1,9 +1,9 @@
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_ast::{MediaType, ParseParams, SourceTextInfo};
use deno_core::error::AnyError;
use deno_core::futures::FutureExt;
use deno_core::{op, Extension, JsRuntime, ModuleCode, ModuleSource, ModuleType, RuntimeOptions};
use futures::executor;
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
@@ -24,12 +24,20 @@ pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
runtime
.execute_script("<runtime>", include_str!("runtime.js"))
.unwrap();
.expect("Failed to execute runtime.js");
let main_module = deno_core::resolve_path(file_path)?;
let mod_id = runtime.load_main_module(&main_module, None).await?;
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
let main_module =
deno_core::resolve_path(file_path, current_dir).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?;
runtime
.run_event_loop(false)
.await
.expect("Failed to run event loop");
result.await?
}
@@ -60,12 +68,14 @@ impl deno_core::ModuleLoader for TsModuleLoader {
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
let module_specifier = module_specifier.clone();
async move {
let path = module_specifier.to_file_path().unwrap();
let path = module_specifier
.to_file_path()
.expect("Failed to convert to file path");
// 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) {
let media_type = MediaType::from_path(&path);
let (module_type, should_transpile) = match media_type {
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
(ModuleType::JavaScript, false)
}
@@ -99,7 +109,7 @@ impl deno_core::ModuleLoader for TsModuleLoader {
// Load and return module.
let module = ModuleSource {
code: code.into_bytes().into_boxed_slice(),
code: ModuleCode::from(code),
module_type,
module_url_specified: module_specifier.to_string(),
module_url_found: module_specifier.to_string(),

View File

@@ -1,10 +1,9 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0;
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
pub trait WindowExt {
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self);
}

76
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,76 @@
{
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "Yaak",
"version": "2023.0.3"
},
"tauri": {
"windows": [],
"allowlist": {
"all": false,
"fs": {
"scope": [
"$RESOURCE/*"
]
},
"shell": {
"all": false,
"open": true
},
"window": {
"startDragging": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "co.schier.yaak",
"longDescription": "",
"resources": [
"plugins/*",
"migrations/*"
],
"shortDescription": "",
"targets": "all",
"deb": {
"depends": []
},
"macOS": {
"exceptionDomain": "",
"frameworks": []
},
"windows": {
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"
},
"updater": {
"active": true,
"dialog": true,
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}
}
}

View File

@@ -1,73 +0,0 @@
[build]
beforeDevCommand = "npm run dev"
beforeBuildCommand = "npm run build"
devPath = "http://localhost:1420"
distDir = "../dist"
withGlobalTauri = false
[package]
productName = "Twosomnia"
version = "0.0.1"
[tauri.allowlist]
all = false
[tauri.allowlist.shell]
all = false
open = true
[tauri.allowlist.window]
startDragging = true
[tauri.allowlist.fs]
scope = [ "$RESOURCE/*" ]
[tauri.bundle]
active = true
category = "DeveloperTool"
copyright = ""
externalBin = [ ]
icon = [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
identifier = "co.schier.twosomnia"
longDescription = ""
resources = [ "plugins/*" ]
shortDescription = ""
targets = "all"
[tauri.bundle.deb]
depends = [ ]
[tauri.bundle.macOS]
exceptionDomain = ""
frameworks = [ ]
[tauri.bundle.windows]
digestAlgorithm = "sha256"
timestampUrl = ""
[tauri.security]
[tauri.updater]
active = false
endpoints = [ ]
pubkey = ""
dialog = true
[[tauri.windows]]
fullscreen = false
height = 800
resizable = true
title = "Twosomnia"
width = 1_400
titleBarStyle = "Overlay"
hiddenTitle = true
[tauri.systemTray]
iconPath = "icons/icon.png"
iconAsTemplate = true

View File

@@ -1,6 +0,0 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log

View File

@@ -1,33 +0,0 @@
[package]
name = "hello"
version = "0.1.0"
authors = ["Gregory Schier <gschier1990@gmail.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2.63"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.6", optional = true }
# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
# compared to the default allocator's ~10K. It is slower than the default
# allocator, however.
wee_alloc = { version = "0.4.5", optional = true }
wasm-bindgen-futures = "0.4.34"
[dev-dependencies]
wasm-bindgen-test = "0.3.13"
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

View File

@@ -1,21 +0,0 @@
use wasm_bindgen::prelude::*;
mod utils;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[wasm_bindgen]
pub fn greet() {
log("Hello from Rust WASM!");
}

View File

@@ -1,10 +0,0 @@
pub fn set_panic_hook() {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}

View File

@@ -1,137 +0,0 @@
import { FormEvent, useState } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
import Editor from './components/Editor/Editor';
import { Input } from './components/Input';
import { HStack, VStack } from './components/Stacks';
import { Button } from './components/Button';
import { DropdownMenuRadio } from './components/Dropdown';
import { WindowDragRegion } from './components/WindowDragRegion';
import { IconButton } from './components/IconButton';
interface Response {
url: string;
method: string;
body: string;
status: string;
elapsed: number;
elapsed2: number;
headers: Record<string, string>;
}
function App() {
const [error, setError] = useState<string | null>(null);
const [response, setResponse] = useState<Response | null>(null);
const [url, setUrl] = useState('https://go-server.schier.dev/debug');
const [loading, setLoading] = useState(false);
const [method, setMethod] = useState<string>('get');
async function sendRequest(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const resp = (await invoke('send_request', { method, url })) as Response;
if (resp.body.includes('<head>')) {
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
}
setLoading(false);
setResponse(resp);
} catch (err) {
setLoading(false);
setError(`${err}`);
}
}
const contentType = response?.headers['content-type']?.split(';')[0] ?? 'text/plain';
return (
<>
<div className="grid grid-cols-[auto_1fr] h-full">
<nav className="w-52 bg-gray-50 h-full border-r border-gray-500/10">
<HStack as={WindowDragRegion} className="pl-24 px-1" items="center" justify="end">
<IconButton icon="archive" size="sm" />
<DropdownMenuRadio
onValueChange={null}
value={'get'}
items={[
{ label: 'This is a cool one', value: 'get' },
{ label: 'But this one is better', value: 'put' },
{ label: 'This one is just alright', value: 'post' },
]}
>
<IconButton icon="camera" size="sm" />
</DropdownMenuRadio>
</HStack>
</nav>
<VStack className="w-full">
<HStack as={WindowDragRegion} items="center" className="pl-4 pr-1">
<h5>Hello, Friend!</h5>
<IconButton icon="gear" className="ml-auto" size="sm" />
</HStack>
<VStack className="p-4 max-w-[35rem] mx-auto" space={3}>
<HStack as="form" className="items-end" onSubmit={sendRequest} space={2}>
<DropdownMenuRadio
onValueChange={setMethod}
value={method}
items={[
{ label: 'GET', value: 'get' },
{ label: 'PUT', value: 'put' },
{ label: 'POST', value: 'post' },
]}
>
<Button disabled={loading} color="secondary" forDropdown>
{method.toUpperCase()}
</Button>
</DropdownMenuRadio>
<HStack>
<Input
hideLabel
name="url"
label="Enter URL"
className="rounded-r-none font-mono"
onChange={(e) => setUrl(e.currentTarget.value)}
value={url}
placeholder="Enter a URL..."
/>
<Button
className="mr-1 rounded-l-none -ml-3"
color="primary"
type="submit"
disabled={loading}
>
{loading ? 'Sending...' : 'Send'}
</Button>
</HStack>
</HStack>
{error && <div className="text-white bg-red-500 px-4 py-1 rounded">{error}</div>}
{response !== null && (
<>
<div className="my-1 italic text-gray-500 text-sm">
{response?.method.toUpperCase()}
&nbsp;&bull;&nbsp;
{response?.status}
&nbsp;&bull;&nbsp;
{response?.elapsed}ms &nbsp;&bull;&nbsp;
{response?.elapsed2}ms
</div>
{contentType.includes('html') ? (
<iframe
title="Response preview"
srcDoc={response.body}
sandbox="allow-scripts allow-same-origin"
className="h-[70vh] w-full rounded-lg"
/>
) : response?.body ? (
<Editor value={response?.body} contentType={contentType} />
) : null}
</>
)}
</VStack>
</VStack>
</div>
</>
);
}
export default App;

View File

Binary file not shown.

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

@@ -0,0 +1,57 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
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 { DialogProvider } from './DialogContext';
import { TauriListeners } from './TauriListeners';
const queryClient = new QueryClient({
logger: undefined,
defaultOptions: {
queries: {
retry: false,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
networkMode: 'offlineFirst',
// It's a desktop app, so this isn't necessary
refetchOnWindowFocus: false,
},
},
});
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
throttleTime: 1000, // 1 second
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<DndProvider backend={HTML5Backend}>
<DialogProvider>
<Suspense>
<AppRouter />
<TauriListeners />
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</DialogProvider>
</DndProvider>
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect } from 'react';
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
useLocation,
} from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
import { setLastLocation } from '../lib/lastLocation';
import RouteError from './RouteError';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <RouterRoot />,
children: [
{
path: '/',
element: <Navigate to={routePaths.workspaces()} replace={true} />,
},
{
path: routePaths.workspaces(),
element: <Workspaces />,
},
{
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
element: <Workspace />,
},
{
path: routePaths.request({
workspaceId: ':workspaceId',
requestId: ':requestId',
}),
element: <Workspace />,
},
],
},
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}
function RouterRoot() {
const { pathname } = useLocation();
useEffect(() => {
setLastLocation(pathname).catch(console.error);
}, [pathname]);
return <Outlet />;
}

View File

@@ -0,0 +1,43 @@
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
label="Username"
name="username"
size="sm"
defaultValue={`${authentication.username}`}
onChange={(username: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { password: r.authentication.password, username },
}));
}}
/>
<Input
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,30 @@
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
label="Token"
name="token"
size="sm"
defaultValue={`${authentication.token}`}
onChange={(token: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { token },
}));
}}
/>
</VStack>
);
}

View File

@@ -1,33 +0,0 @@
import classnames from 'classnames';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { Icon } from './Icon';
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
color?: 'primary' | 'secondary';
size?: 'sm' | 'md';
forDropdown?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, children, size = 'md', forDropdown, color, ...props }: ButtonProps,
ref,
) {
return (
<button
ref={ref}
className={classnames(
className,
'rounded-md text-white flex items-center',
{ 'h-10 px-4': size === 'md' },
{ 'h-8 px-3': size === 'sm' },
{ 'hover:bg-gray-500/[0.1] active:bg-gray-500/[0.15]': color === undefined },
{ 'bg-blue-500 hover:bg-blue-500/90 active:bg-blue-500/80': color === 'primary' },
{ 'bg-violet-500 hover:bg-violet-500/90 active:bg-violet-500/80': color === 'secondary' },
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
</button>
);
});

View File

@@ -0,0 +1,57 @@
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(({ id, render, ...props }) => (
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
))}
</DialogContext.Provider>
);
};
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

@@ -1,311 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { DropdownMenuRadioGroup } from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion';
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
HamburgerMenuIcon,
} from '@radix-ui/react-icons';
import { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
import { Button } from './Button';
import classnames from 'classnames';
import { HotKey } from './HotKey';
interface DropdownMenuRadioProps {
children: ReactNode;
onValueChange: ((value: string) => void) | null;
value: string;
items: {
label: string;
value: string;
}[];
}
export function DropdownMenuRadio({
children,
items,
onValueChange,
value,
}: DropdownMenuRadioProps) {
return (
<DropdownMenu.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuRadioGroup onValueChange={onValueChange ?? undefined} value={value}>
{items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu.Root>
);
}
export function Dropdown() {
const [bookmarksChecked, setBookmarksChecked] = useState(true);
const [urlsChecked, setUrlsChecked] = useState(false);
const [person, setPerson] = useState('pedro');
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button aria-label="Customise options">
<HamburgerMenuIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem rightSlot={<HotKey>T</HotKey>}>New Tab</DropdownMenuItem>
<DropdownMenuItem rightSlot={<HotKey>N</HotKey>}>New Window</DropdownMenuItem>
<DropdownMenuItem disabled rightSlot={<HotKey>N</HotKey>}>
New Private Window
</DropdownMenuItem>
<DropdownMenu.Sub>
<DropdownMenuSubTrigger rightSlot={<ChevronRightIcon />}>
More Tools
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem rightSlot={<HotKey>S</HotKey>}>Save Page As</DropdownMenuItem>
<DropdownMenuItem>Create Shortcut</DropdownMenuItem>
<DropdownMenuItem>Name Window</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Developer Tools</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenu.Sub>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={bookmarksChecked}
onCheckedChange={(v) => setBookmarksChecked(!!v)}
rightSlot={<HotKey>B</HotKey>}
leftSlot={
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
<CheckIcon />
</DropdownMenu.ItemIndicator>
}
>
Show Bookmarks
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={urlsChecked}
onCheckedChange={(v) => setUrlsChecked(!!v)}
leftSlot={
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
<CheckIcon />
</DropdownMenu.ItemIndicator>
}
>
Show Full URLs
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>People</DropdownMenuLabel>
<DropdownMenu.RadioGroup value={person} onValueChange={setPerson}>
<DropdownMenuRadioItem value="pedro">Pedro Duarte</DropdownMenuRadioItem>
<DropdownMenuRadioItem className="DropdownMenuRadioItem" value="colm">
Colm Tuite
</DropdownMenuRadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu.Root>
);
}
const dropdownMenuClasses = 'bg-background rounded-md shadow-lg p-1.5 border border-gray-100';
interface DropdownMenuPortalProps {
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
return (
<DropdownMenu.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
</DropdownMenu.Portal>
);
}
const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuContentProps>(
function DropdownMenuContent(
{ className, children, ...props }: DropdownMenu.DropdownMenuContentProps,
ref,
) {
return (
<DropdownMenu.Content
ref={ref}
align="start"
className={classnames(className, dropdownMenuClasses, 'mt-1')}
{...props}
>
{children}
</DropdownMenu.Content>
);
},
);
type DropdownMenuItemProps = DropdownMenu.DropdownMenuItemProps & ItemInnerProps;
function DropdownMenuItem({
leftSlot,
rightSlot,
className,
children,
...props
}: DropdownMenuItemProps) {
return (
<DropdownMenu.Item
asChild
className={classnames(className, { 'opacity-30': props.disabled })}
{...props}
>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</DropdownMenu.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<
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
'leftSlot'
>;
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
return (
<DropdownMenu.RadioItem asChild {...props}>
<ItemInner
leftSlot={
<DropdownMenu.ItemIndicator>
<DotFilledIcon />
</DropdownMenu.ItemIndicator>
}
rightSlot={rightSlot}
>
{children}
</ItemInner>
</DropdownMenu.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 }: DropdownMenu.DropdownMenuLabelProps) {
return (
<DropdownMenu.Label asChild {...props}>
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
{children}
</ItemInner>
</DropdownMenu.Label>
);
}
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
return (
<DropdownMenu.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
function DropdownMenuTrigger({ className, ...props }: DropdownMenu.DropdownMenuTriggerProps) {
return (
<DropdownMenu.Trigger
asChild
className={classnames(className, 'focus:outline-none')}
{...props}
/>
);
}
interface ItemInnerProps extends HTMLAttributes<HTMLDivElement> {
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
}
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
ref,
) {
return (
<div
ref={ref}
className={classnames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700',
{
'focus:bg-gray-50 focus:text-gray-900 rounded': !noHover,
},
)}
{...props}
>
<div className="w-7">{leftSlot}</div>
<div>{children}</div>
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
</div>
);
});

View File

@@ -1,54 +0,0 @@
.cm-editor {
width: 100%;
overflow: hidden;
border-radius: var(--border-radius-lg);
}
.cm-editor .cm-scroller {
border-radius: var(--border-radius-lg);
background-color: hsl(var(--color-gray-50) / 0.5);
}
.cm-editor .cm-line {
padding-left: 1em;
padding-right: 1.5em;
color: hsl(var(--color-gray-900));
}
.cm-editor .cm-gutters {
background-color: transparent;
border-right: 0;
color: hsl(var(--color-gray-300));
}
.cm-editor .cm-foldPlaceholder {
background-color: hsl(var(--color-gray-100));
border: 1px solid hsl(var(--color-gray-200));
padding: 0 0.3em;
}
.cm-editor .cm-activeLineGutter,
.cm-editor .cm-activeLine {
background-color: hsl(var(--color-gray-50));
}
.cm-editor * {
cursor: text;
}
.cm-editor.cm-focused {
outline: 0;
box-shadow: 0 0 0 2pt rgba(180, 180, 180, 0.1);
}
.cm-editor .cm-cursor {
border-left: 2px solid red;
}
.cm-editor .cm-selectionBackground {
background-color: hsl(var(--color-gray-100));
}
.cm-editor.cm-focused .cm-selectionBackground {
background-color: hsl(var(--color-gray-100));
}

View File

@@ -1,12 +0,0 @@
import useCodeMirror from '../../hooks/useCodemirror';
import './Editor.css';
interface Props {
contentType: string;
value: string;
}
export default function Editor(props: Props) {
const { ref } = useCodeMirror({ value: props.value, contentType: props.contentType });
return <div ref={ref} className="m-0 text-sm overflow-hidden" />;
}

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,118 @@
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 { 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 introspection = 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, introspection.data);
}, [introspection.data]);
const dialog = useDialog();
return (
<div className="pb-2 h-full grid 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={
(introspection.error || introspection.isLoading) && (
<Button
size="xs"
color={introspection.error ? 'danger' : 'gray'}
isLoading={introspection.isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'sm',
render: () => (
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
),
});
}}
>
{introspection.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
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import classnames from 'classnames';
import {HTMLAttributes} from 'react';
const colsClasses = {
none: 'grid-cols-none',
1: 'grid-cols-1',
2: 'grid-cols-2',
};
const rowsClasses = {
none: 'grid-rows-none',
1: 'grid-rows-1',
2: 'grid-rows-2',
};
const gapClasses = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
};
type Props = HTMLAttributes<HTMLElement> & {
rows?: keyof typeof rowsClasses;
cols?: keyof typeof colsClasses;
gap?: keyof typeof gapClasses;
};
export function Grid({ className, cols, gap, ...props }: Props) {
return (
<div
className={classnames(className, 'grid', cols && colsClasses[cols], gap && gapClasses[gap])}
{...props}
/>
);
}

View File

@@ -0,0 +1,67 @@
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';
type Props = {
forceUpdateKey: string;
headers: HttpRequest['headers'];
onChange: (headers: HttpRequest['headers']) => void;
};
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return (
<PairEditor
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder="Header-Name"
/>
);
}
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;
}
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
};

View File

@@ -1,34 +0,0 @@
import { ComponentType } from 'react';
import {
ArchiveIcon,
CameraIcon,
ChevronDownIcon,
GearIcon,
HomeIcon,
TriangleDownIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
type IconName = 'archive' | 'home' | 'camera' | 'gear' | 'triangle-down';
const icons: Record<IconName, ComponentType> = {
archive: ArchiveIcon,
home: HomeIcon,
camera: CameraIcon,
gear: GearIcon,
'triangle-down': TriangleDownIcon,
};
export interface IconProps {
icon: IconName;
className?: string;
}
export function Icon({ icon, className }: IconProps) {
const Component = icons[icon];
return (
<div className={classnames(className, 'flex items-center')}>
<Component />
</div>
);
}

View File

@@ -1,16 +0,0 @@
import { forwardRef } from 'react';
import { Icon, IconProps } from './Icon';
import { Button, ButtonProps } from './Button';
type Props = ButtonProps & IconProps;
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, ...props }: Props,
ref,
) {
return (
<Button ref={ref} className="group" {...props}>
<Icon icon={icon} className="text-gray-700 group-hover:text-gray-900" />
</Button>
);
});

View File

@@ -1,34 +0,0 @@
import { InputHTMLAttributes } from 'react';
import classnames from 'classnames';
import { VStack } from './Stacks';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
}
export function Input({ label, labelClassName, hideLabel, className, name, ...props }: Props) {
const id = `input-${name}`;
return (
<VStack className="w-full">
<label
htmlFor={name}
className={classnames(labelClassName, 'font-semibold text-sm uppercase text-gray-700', {
'sr-only': hideLabel,
})}
>
{label}
</label>
<input
id={id}
className={classnames(
className,
'w-0 min-w-[100%] border-2 border-gray-100 bg-gray-50 h-10 pl-3 pr-2 rounded-md text-sm focus:outline-none',
)}
{...props}
/>
</VStack>
);
}

View File

@@ -0,0 +1,46 @@
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import type { ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Portal } from './Portal';
interface Props {
children: ReactNode;
portalName: string;
open: boolean;
onClose?: () => void;
zIndex?: keyof typeof zIndexes;
}
const zIndexes: Record<number, string> = {
10: 'z-10',
20: 'z-20',
30: 'z-30',
40: 'z-40',
50: 'z-50',
};
export function Overlay({ 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="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
/>
{/* Add region to still be able to drag the window */}
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
{children}
</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,52 @@
import type { HTMLAttributes, ReactElement } from 'react';
import { useConfirm } from '../hooks/useConfirm';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRequest } from '../hooks/useRequest';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
interface Props {
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const request = useRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId ?? null);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const confirm = useConfirm();
return (
<Dropdown
items={[
{
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
},
{
label: 'Delete',
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteRequest.mutate();
}
},
leftSlot: <Icon icon="trash" />,
},
]}
>
{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

@@ -0,0 +1,241 @@
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 { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useTauriEvent } from '../hooks/useTauriEvent';
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 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';
interface Props {
style?: CSSProperties;
fullHeight: boolean;
className?: string;
}
const useActiveTab = createGlobalState<string>('body');
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
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],
);
useTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', { requestId: activeRequestId });
},
[activeRequestId],
);
return (
<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"
>
<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
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
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,131 @@
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 { useKeyValue } from '../hooks/useKeyValue';
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 widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
const width = widthKv.value ?? DEFAULT;
const height = heightKv.value ?? 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 ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
[heightKv, vertical, widthKv],
);
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,
);
heightKv.set(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
MIN_WIDTH_PX,
maxWidthPx,
);
widthKv.set(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, heightKv, widthKv],
);
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 cursor-ew-resize',
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-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 cursor-ew-resize',
vertical && 'cursor-ns-resize',
!vertical && 'cursor-ew-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 font-mono">
{headers.map((h, i) => {
return (
<HStack
space={3}
key={i}
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-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

@@ -0,0 +1,185 @@
import classnames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText';
import { ResponseHeaders } from './ResponseHeaders';
interface Props {
style?: CSSProperties;
className?: string;
}
const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const responses = useResponses(activeRequestId);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: responses[responses.length - 1] ?? null;
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
const tabs = useMemo(
() => [
{ label: 'Body', value: 'body' },
{
label: (
<div className="flex items-center">
Headers
<CountBadge
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
/>
</div>
),
value: 'headers',
},
],
[activeResponse?.headers],
);
return (
<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',
)}
>
<HStack
alignItems="center"
className={classnames(
'italic 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 && (
<>
<div className="whitespace-nowrap p-3 py-2">
<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>
<Dropdown
items={[
{
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
onSelect: toggleViewMode,
},
{ type: 'separator', label: 'Actions' },
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
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, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
</>
)}
</HStack>
{activeResponse?.error ? (
<Banner className="m-2">{activeResponse.error}</Banner>
) : (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
className="px-3"
tabs={tabs}
>
<TabContent value="body">
{activeResponse === null ? (
<EmptyStateText>No Response</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview
body={activeResponse.body}
contentType={contentType}
url={activeResponse.url}
/>
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
</Tabs>
)}
</div>
);
});

View File

@@ -0,0 +1,24 @@
import { useRouteError } from 'react-router-dom';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !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>
</VStack>
</div>
);
}

View File

@@ -0,0 +1,295 @@
import classnames from 'classnames';
import type { ForwardedRef, KeyboardEvent } 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 { useActiveRequest } from '../hooks/useActiveRequest';
import { useRequests } from '../hooks/useRequests';
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 { DropMarker } from './DropMarker';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
interface Props {
className?: string;
}
enum ItemTypes {
REQUEST = 'request',
}
export const Sidebar = memo(function Sidebar({ className }: Props) {
const sidebarRef = useRef<HTMLDivElement>(null);
const unorderedRequests = useRequests();
const activeRequest = useActiveRequest();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
return (
<div className="relative h-full">
<div
ref={sidebarRef}
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
>
<VStack
as="ul"
className="relative py-3 overflow-y-auto overflow-x-visible"
draggable={false}
>
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
</VStack>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton />
</HStack>
</div>
</div>
);
});
function SidebarItems({
requests,
activeRequestId,
}: {
requests: HttpRequest[];
activeRequestId?: string;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
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);
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);
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[hoveredIndex + 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],
);
return (
<>
{requests.map((r, i) => (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem
key={r.id}
requestId={r.id}
requestName={r.name}
workspaceId={r.workspaceId}
active={r.id === activeRequestId}
onMove={handleMove}
onEnd={handleEnd}
/>
</Fragment>
))}
{hoveredIndex === requests.length && <DropMarker />}
</>
);
}
type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
workspaceId: string;
active?: boolean;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
setEditing(false);
},
[updateRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
el?.focus();
el?.select();
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
},
[active],
);
const handleInputKeyDown = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
return (
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
<div className="relative">
<Button
color="custom"
size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames(
editing && 'focus-within:border-focus',
active
? 'bg-highlight text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<RequestActionsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity !opacity-0',
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
)}
/>
</RequestActionsDropdown>
</div>
</li>
);
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
};
type DragItem = {
id: string;
workspaceId: string;
requestName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
workspaceId,
active,
onMove,
onEnd,
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (item, 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, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
}),
[onEnd],
);
connectDrag(ref);
connectDrop(ref);
return (
<SidebarItem
ref={ref}
className={classnames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
workspaceId={workspaceId}
active={active}
/>
);
});

View File

@@ -0,0 +1,31 @@
import { memo, useCallback } from 'react';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
export const SidebarActions = memo(function SidebarDisplayToggle() {
const { hidden, toggle } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true });
const handleCreateRequest = useCallback(() => {
createRequest.mutate({ name: 'New Request' });
}, [createRequest]);
return (
<>
<IconButton
onClick={toggle}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<IconButton
onClick={handleCreateRequest}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon="plusCircle"
/>
</>
);
});

View File

@@ -1,93 +0,0 @@
import React, { Children, Fragment, HTMLAttributes, ReactNode } from 'react';
import classnames from 'classnames';
const spaceClassesX = {
0: 'pr-0',
1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
};
const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
};
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
return (
<BaseStack className={classnames(className, 'w-full flex-row')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
children: ReactNode;
}
export function VStack({ className, space, children, ...props }: VStackProps) {
return (
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
interface BaseStackProps extends HTMLAttributes<HTMLElement> {
items?: 'start' | 'center';
justify?: 'start' | 'end';
as?: React.ElementType;
}
function BaseStack({ className, items, justify, as = 'div', ...props }: BaseStackProps) {
const Component = as;
return (
<Component
className={classnames(className, 'flex flex-grow-0', {
'items-center': items === 'center',
'items-start': items === 'start',
'justify-start': justify === 'start',
'justify-end': justify === 'end',
})}
{...props}
/>
);
}

View File

@@ -0,0 +1,121 @@
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 { useTauriEvent } from '../hooks/useTauriEvent';
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';
export function TauriListeners() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
useTauriEvent<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) {
if (payload.model) {
console.log('Unrecognized created model:', payload);
}
return;
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
}
});
useTauriEvent<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) {
if (payload.model) {
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)),
);
}
});
useTauriEvent<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);
}
});
useTauriEvent<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 === 'http_response') return false;
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
return true;
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { useTheme } from '../hooks/useTheme';
import { IconButton } from './core/IconButton';
export function ToggleThemeButton() {
const { appearance, toggleAppearance } = useTheme();
return (
<IconButton
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
icon={appearance === 'dark' ? 'moon' : 'sun'}
onClick={toggleAppearance}
/>
);
}

View File

@@ -0,0 +1,84 @@
import classnames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
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';
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 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],
);
useTauriEvent('focus_url', () => {
inputRef.current?.focus();
});
return (
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
<Input
ref={inputRef}
size="sm"
hideLabel
useTemplating
contentType="url"
className="px-0"
name="url"
label="Enter URL"
forceUpdateKey={updateKey}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
placeholder="https://example.com"
leftSlot={
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="mx-0.5 h-full my-1"
/>
}
rightSlot={
<IconButton
size="xs"
iconSize="sm"
title="Send Request"
type="submit"
className="w-8 mr-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
/>
}
/>
</form>
);
});

View File

@@ -1,14 +0,0 @@
import classnames from 'classnames';
import { HTMLAttributes } from 'react';
type Props = HTMLAttributes<HTMLDivElement>;
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
className={classnames(className, 'w-full h-11 border-b border-gray-500/10')}
data-tauri-drag-region=""
{...props}
/>
);
}

View File

@@ -0,0 +1,184 @@
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
HTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
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 { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
const { show, hide, 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,
);
useTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide && !hidden) {
setFloating(true);
hide();
} else if (!shouldHide && hidden) {
setFloating(false);
show();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
setWidth(startWidth + (e.clientX - mouseStartX));
},
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, width],
);
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
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',
)}
>
<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>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<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 border-r border-highlight')}
>
<Sidebar />
</div>
<ResizeHandle
className="-translate-x-3"
justify="end"
side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart}
onReset={resetWidth}
/>
</>
)}
<RequestResponse style={body} />
</div>
);
}
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
function HeaderSize({ className, ...props }: HeaderSizeProps) {
return (
<div
className={classnames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,89 @@
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useConfirm } from '../hooks/useConfirm';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useRoutes } from '../hooks/useRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
type Props = {
className?: string;
};
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
const routes = useRoutes();
const confirm = useConfirm();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
label: w.name,
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
if (w.id === activeWorkspaceId) return;
routes.navigate('workspace', { workspaceId: w.id });
},
}));
return [
...workspaceItems,
{
type: 'separator',
label: 'Actions',
},
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />,
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteWorkspace.mutate();
}
},
},
];
}, [
workspaces,
activeWorkspaceId,
routes,
createWorkspace,
confirm,
activeWorkspace?.name,
deleteWorkspace,
]);
return (
<Dropdown items={items}>
<Button
size="sm"
className={classnames(className, 'text-gray-800 !px-2 truncate')}
forDropdown
>
{activeWorkspace?.name}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,43 @@
import classnames from 'classnames';
import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
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 className="pointer-events-auto" />
</HStack>
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
</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

@@ -0,0 +1,16 @@
import { Navigate } from 'react-router-dom';
import { useRoutes } from '../hooks/useRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
export default function Workspaces() {
const routes = useRoutes();
const workspaces = useWorkspaces();
const workspace = workspaces[0];
if (workspace === undefined) {
return <Heading>There are no workspaces</Heading>;
}
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
}

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

@@ -0,0 +1,81 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
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;
color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
disabled?: boolean;
title?: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _Button = forwardRef<any, ButtonProps>(function Button(
{
to,
isLoading,
className,
children,
forDropdown,
color,
justify = 'center',
size = 'md',
...props
}: ButtonProps,
ref,
) {
const classes = useMemo(
() =>
classnames(
className,
'opacity-90 hover:opacity-100',
'outline-none whitespace-nowrap',
'focus-visible-or-class:ring',
'rounded-md flex items-center',
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',
),
[color, size, justify, className],
);
if (typeof to === 'string') {
return (
<Link ref={ref} to={to} className={classes} {...props}>
{children}
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
}
});
export const Button = memo(_Button);

View File

@@ -0,0 +1,38 @@
import classnames from 'classnames';
import { useCallback } from 'react';
import { Icon } from './Icon';
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function Checkbox({ checked, onChange, className, disabled }: Props) {
const handleClick = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
return (
<button
role="checkbox"
aria-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={handleClick}
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,17 @@
interface Props {
count: number;
}
export function CountBadge({ count }: Props) {
if (count === 0) return null;
return (
<>
<div
aria-hidden
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
>
{count}
</div>
</>
);
}

View File

@@ -0,0 +1,85 @@
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';
export interface DialogProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: ReactNode;
description?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'full' | 'dynamic';
hideX?: boolean;
}
export function Dialog({
children,
className,
size = 'full',
open,
onClose,
title,
description,
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 (
<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,
'relative bg-gray-50 pointer-events-auto',
'max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
{!hideX && (
<IconButton
onClick={onClose}
title="Close dialog"
aria-label="Close"
icon="x"
size="sm"
className="ml-auto absolute right-1 top-1"
/>
)}
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div>
</motion.div>
</div>
</div>
</Overlay>
);
}

View File

@@ -0,0 +1,279 @@
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import { Separator } from './Separator';
import { VStack } from './Stacks';
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
};
export type DropdownItem =
| {
type?: 'default';
label: string;
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
onSelect?: () => void;
}
| DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
}
export function Dropdown({ children, items }: DropdownProps) {
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setOpen((o) => !o);
}),
};
return cloneElement(existingChild, props);
}, [children]);
const handleClose = useCallback(() => {
setOpen(false);
ref.current?.focus();
}, []);
useEffect(() => {
ref.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
const triggerRect = useMemo(() => {
if (!open) return null;
return ref.current?.getBoundingClientRect();
}, [open]);
return (
<>
{child}
{open && triggerRect && (
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
)}
</>
);
}
interface MenuProps {
className?: string;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
const containerRef = useRef<HTMLDivElement | null>(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 });
}, []);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
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;
});
});
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
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;
});
});
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 handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
setSelectedIndex(index);
},
[items],
);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
return (
<Portal name="dropdown">
<FocusTrap>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
<motion.div
tabIndex={0}
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={i + item.label}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</FocusTrap>
</Portal>
);
}
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}
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
onFocus={handleFocus}
onClick={handleClick}
className={classnames(
className,
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
'focus:bg-highlight focus:text-gray-900 rounded',
)}
{...props}
>
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
<div>{item.label}</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</button>
);
}

View File

@@ -0,0 +1,207 @@
.cm-wrapper {
@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 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 opacity-95;
.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 hover:border-opacity-80;
/* Bring above on hover */
@apply hover:z-10 relative;
}
}
&.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 h-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
/*
* 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;
}
}
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@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.38em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@apply rotate-[-135deg];
}
.cm-editor .fold-gutter-icon:hover {
@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;
}
.cm-editor .cm-activeLineGutter {
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-600;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
}
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
&.cm-completionInfo-right {
@apply ml-1;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
}
&.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-highlight text-gray-900;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
.cm-completionLabel {
}
.cm-completionDetail {
@apply ml-auto;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -0,0 +1,297 @@
import { defaultKeymap } from '@codemirror/commands';
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 { EditorView } from 'codemirror';
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';
// 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;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
singleLine?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
actions?: ReactNode;
}
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
forceUpdateKey,
onChange,
onFocus,
onBlur,
className,
singleLine,
format,
autocomplete,
actions,
}: EditorProps,
ref,
) {
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]);
// 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 language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating]);
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 languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
...getExtensions({
container,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
readOnly,
singleLine,
}),
],
});
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cmContainer = (
<div
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',
)}
/>
);
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,
onChange,
onFocus,
onBlur,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
undefined;
return [
...baseExtensions,
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(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 }));
}
},
}),
]
: []),
// Handle onFocus
EditorView.domEventHandlers({
focus: onFocus.current,
blur: onBlur.current,
}),
// Handle onChange
EditorView.updateListener.of((update) => {
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 = '',
}: {
parent: HTMLDivElement;
className?: string;
}) => {
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
const classList = className?.split(/\s+/) ?? [];
const bgClasses = classList
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
.map((c) => c.replace(/^bg-/, '!bg-')) // !important
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
if (gutterEl) {
gutterEl?.classList.add(...bgClasses);
}
};
const placeholderElFromText = (text: string) => {
const el = document.createElement('div');
el.innerHTML = text.replace('\n', '<br/>');
return el;
};

View File

@@ -0,0 +1,35 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import { EditorView } from 'codemirror';
import { debounce } from '../../../lib/debounce';
/*
* Debounce autocomplete until user stops typing for `millis` milliseconds.
*/
export function debouncedAutocompletionDisplay({ millis }: { millis: number }) {
// TODO: Figure out how to show completion without setting context.explicit = true
const debouncedStartCompletion = debounce(function (view: EditorView) {
startCompletion(view);
}, millis);
return EditorView.updateListener.of(({ view, docChanged }) => {
// const completions = currentCompletions(view.state);
// const status = completionStatus(view.state);
if (!view.hasFocus) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
if (view.state.doc.length === 0) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
// If the document hasn't changed, we don't need to do anything
if (docChanged) {
debouncedStartCompletion(view);
}
});
}

View File

@@ -0,0 +1,162 @@
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
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';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state';
import {
crosshairCursor,
drawSelection,
dropCursor,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
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';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
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, 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))' },
]);
// export const defaultHighlightStyle = HighlightStyle.define([
// { tag: t.meta, color: '#404740' },
// { tag: t.link, textDecoration: 'underline' },
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
// { tag: t.emphasis, fontStyle: 'italic' },
// { tag: t.strong, fontWeight: 'bold' },
// { tag: t.strikethrough, textDecoration: 'line-through' },
// { tag: t.keyword, color: '#708' },
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
// { tag: [t.literal, t.inserted], color: '#164' },
// { tag: [t.string, t.deleted], color: '#a11' },
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
// { tag: t.definition(t.variableName), color: '#00f' },
// { tag: t.local(t.variableName), color: '#30a' },
// { tag: [t.typeName, t.namespace], color: '#085' },
// { tag: t.className, color: '#167' },
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
// { tag: t.definition(t.propertyName), color: '#00c' },
// { tag: t.comment, color: '#940' },
// { tag: t.invalid, color: '#f00' },
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
'application/xml': xml(),
'text/xml': xml(),
url: url(),
};
export function getLanguageExtension({
contentType,
useTemplating = false,
autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
return base ? base : [];
}
return twig(base, 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: 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),
];
export const multiLineExtensions = [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
closeBrackets(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
highlightSelectionMatches({ minSelectionLength: 2 }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
]),
];

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