mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-13 11:20:12 +02:00
Compare commits
742 Commits
v2026.4.0-
...
v2024.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb5f9a4671 | ||
|
|
ad1a4eadd9 | ||
|
|
69a151bfe5 | ||
|
|
d8d2f44723 | ||
|
|
d5ea03ce91 | ||
|
|
816bc543d7 | ||
|
|
36d8c56872 | ||
|
|
a7bb5605ab | ||
|
|
31147475f3 | ||
|
|
5a2d510d07 | ||
|
|
4a70c5415b | ||
|
|
ad796275b6 | ||
|
|
31f5163ee3 | ||
|
|
1a6a75ca13 | ||
|
|
edad9e2d68 | ||
|
|
d5d0edb0b0 | ||
|
|
8cf45ba97f | ||
|
|
6749fa9348 | ||
|
|
0f739834c8 | ||
|
|
2d69b83765 | ||
|
|
fa1765f356 | ||
|
|
d1a0265ea5 | ||
|
|
bc191fec95 | ||
|
|
43d042ae68 | ||
|
|
bb47fda7e1 | ||
|
|
f98f541a3d | ||
|
|
4ea943d7f1 | ||
|
|
a6fac2460b | ||
|
|
50410d262f | ||
|
|
388d227572 | ||
|
|
d07e80cc19 | ||
|
|
8d987cff31 | ||
|
|
c46e976932 | ||
|
|
e33f273d7b | ||
|
|
f16ced534c | ||
|
|
f05abf97a4 | ||
|
|
71b06c5261 | ||
|
|
6639b07568 | ||
|
|
b196e51f1f | ||
|
|
edc4fe3d9a | ||
|
|
74065a320c | ||
|
|
87946c6c9f | ||
|
|
4a58f73aa4 | ||
|
|
bd17650799 | ||
|
|
db6a7dcabb | ||
|
|
00b1f90074 | ||
|
|
e292235792 | ||
|
|
5f86802d88 | ||
|
|
acb7f2e49b | ||
|
|
e2a15609bf | ||
|
|
aa3bfd78c4 | ||
|
|
23c4971127 | ||
|
|
40669217fb | ||
|
|
8089ea87e8 | ||
|
|
9de24e3a40 | ||
|
|
5afb8e7383 | ||
|
|
b3e3f22211 | ||
|
|
1ff6ff16b3 | ||
|
|
b8a692f1a5 | ||
|
|
5506cdd05f | ||
|
|
4180fecb4b | ||
|
|
fa257fdb18 | ||
|
|
2da141ea16 | ||
|
|
1993361f87 | ||
|
|
a5dd3beb73 | ||
|
|
17423f8c54 | ||
|
|
8f495b9ade | ||
|
|
46b9b758fe | ||
|
|
b0e84aac0c | ||
|
|
20de2aeacc | ||
|
|
7198534640 | ||
|
|
7e8ec36474 | ||
|
|
52d1602d35 | ||
|
|
e5731ceb1f | ||
|
|
3ed5a47a83 | ||
|
|
262a29ca5d | ||
|
|
4a3e599128 | ||
|
|
7ebe844643 | ||
|
|
a49b72eebc | ||
|
|
bba3afa0b7 | ||
|
|
221e768b33 | ||
|
|
c2dc7e0f4a | ||
|
|
9e065c34ee | ||
|
|
2f91d541c5 | ||
|
|
948fd487ab | ||
|
|
ed6a5386a2 | ||
|
|
8a24c48fd3 | ||
|
|
d726a6f5bf | ||
|
|
8d2a2a8532 | ||
|
|
b838a6ffc1 | ||
|
|
2174a91b64 | ||
|
|
083f83ccab | ||
|
|
4f749be2e2 | ||
|
|
cefdc3ecf3 | ||
|
|
02960d2d64 | ||
|
|
9e5226aa83 | ||
|
|
63d7a44586 | ||
|
|
c851dfe206 | ||
|
|
6adc15a249 | ||
|
|
9ac7aac296 | ||
|
|
325d63e1b7 | ||
|
|
e639a77165 | ||
|
|
c075efc752 | ||
|
|
c4f42f71c3 | ||
|
|
535adfe200 | ||
|
|
85fa159f0d | ||
|
|
fd2fe46c95 | ||
|
|
6e52f35626 | ||
|
|
a0d1e7023d | ||
|
|
97a2f00d59 | ||
|
|
50ad4efad7 | ||
|
|
79a3d9c8df | ||
|
|
b8e20d885f | ||
|
|
752eb3dbd5 | ||
|
|
616acdfb56 | ||
|
|
b2bcbababe | ||
|
|
9f5a3ef96a | ||
|
|
d2c5bdc3c8 | ||
|
|
0d6899a12c | ||
|
|
1b25cb0c4c | ||
|
|
783b7222df | ||
|
|
ff3165ab30 | ||
|
|
9780dc88a1 | ||
|
|
e4f0d2a341 | ||
|
|
bcf0ae159d | ||
|
|
5664d41073 | ||
|
|
e75e6865ea | ||
|
|
fd5b495b70 | ||
|
|
16506d1ddd | ||
|
|
e3016f7100 | ||
|
|
766da4327c | ||
|
|
6f389b0010 | ||
|
|
007ea88edd | ||
|
|
5409678855 | ||
|
|
4c6bd63b8b | ||
|
|
8db80d2e97 | ||
|
|
c80fca8063 | ||
|
|
7384398813 | ||
|
|
b57ea8adeb | ||
|
|
8ff2caf3c3 | ||
|
|
a521b8f308 | ||
|
|
50ba167516 | ||
|
|
cb102657ea | ||
|
|
a7d9e2432b | ||
|
|
d842b168e6 | ||
|
|
870cb25980 | ||
|
|
fde0c5540b | ||
|
|
2ec9a1c19d | ||
|
|
c2f5a3bf45 | ||
|
|
7c18eeae8c | ||
|
|
d7a1b4b7bc | ||
|
|
4566ede184 | ||
|
|
f45c898be0 | ||
|
|
4e1700f8a4 | ||
|
|
f14311d14a | ||
|
|
470a7e2278 | ||
|
|
2d67be481d | ||
|
|
9f6ddb1558 | ||
|
|
853f07b9af | ||
|
|
0eb6358387 | ||
|
|
d43e045f25 | ||
|
|
17432fca29 | ||
|
|
d5931660c2 | ||
|
|
cd7678b7a1 | ||
|
|
706be1188b | ||
|
|
8989b61a13 | ||
|
|
a997944f16 | ||
|
|
f8e8f5d3f2 | ||
|
|
812e5238ac | ||
|
|
16d4f2952d | ||
|
|
ac9a6d5871 | ||
|
|
4fcf1df61f | ||
|
|
394beb374e | ||
|
|
ba4d1063e3 | ||
|
|
2bd9b436e6 | ||
|
|
915a59dec4 | ||
|
|
ae2b746cb2 | ||
|
|
b04cff153b | ||
|
|
bd8e71e567 | ||
|
|
562a36d616 | ||
|
|
c85a11edf1 | ||
|
|
ef7f942a8f | ||
|
|
a7f2a86d71 | ||
|
|
bf90f84d16 | ||
|
|
4284aa2549 | ||
|
|
60773cab53 | ||
|
|
e2c17873ae | ||
|
|
88982156ee | ||
|
|
722c8a1c6b | ||
|
|
8c15274786 | ||
|
|
1abba4980a | ||
|
|
3a340999ec | ||
|
|
b7261e77aa | ||
|
|
23431b40e9 | ||
|
|
04f31cd4a7 | ||
|
|
7d82a7e74a | ||
|
|
d31255d987 | ||
|
|
e53693f605 | ||
|
|
67aa7b7268 | ||
|
|
e27ed9becc | ||
|
|
22d21af3c2 | ||
|
|
67000af7f9 | ||
|
|
b84c7ba50c | ||
|
|
a0b3f86462 | ||
|
|
6a8395660d | ||
|
|
2c041fbac6 | ||
|
|
1eed0e8f22 | ||
|
|
63a0ed273d | ||
|
|
d0be5ca515 | ||
|
|
b964c942d6 | ||
|
|
a05fc5fd20 | ||
|
|
de183abd24 | ||
|
|
5c44df7b00 | ||
|
|
dbdce4cf9a | ||
|
|
219a6b78da | ||
|
|
fb11aff03f | ||
|
|
15714ae188 | ||
|
|
ce116d032d | ||
|
|
6f41df6e52 | ||
|
|
0853d2ca95 | ||
|
|
6798331ce5 | ||
|
|
5ffc75e0ad | ||
|
|
bf92371a49 | ||
|
|
bd3da86317 | ||
|
|
3db3d42246 | ||
|
|
de8bf3ca70 | ||
|
|
8bc131de6c | ||
|
|
efce69292d | ||
|
|
0ccc893440 | ||
|
|
1f9756c917 | ||
|
|
be8f0e4521 | ||
|
|
bcdf51d231 | ||
|
|
1a1553eebd | ||
|
|
321c3862fe | ||
|
|
466d412e65 | ||
|
|
86f50b826f | ||
|
|
ac1e646e68 | ||
|
|
33374eefc7 | ||
|
|
7047df4f7e | ||
|
|
c8bd4d0ae0 | ||
|
|
1e79f76701 | ||
|
|
18852dca06 | ||
|
|
408e7e80b7 | ||
|
|
fc185de023 | ||
|
|
bb9d3a42f3 | ||
|
|
baf0f4291d | ||
|
|
536066142c | ||
|
|
04cf16497d | ||
|
|
feb5972090 | ||
|
|
77bf5a58d8 | ||
|
|
3539642491 | ||
|
|
08abea6a6f | ||
|
|
0045b85f00 | ||
|
|
4b34c3d101 | ||
|
|
4af0a15d9f | ||
|
|
3a4a76c58d | ||
|
|
3086d815c1 | ||
|
|
a48a9eab4a | ||
|
|
48664c66e5 | ||
|
|
7aee5176a9 | ||
|
|
0da68ced18 | ||
|
|
39f7d9c113 | ||
|
|
138943bfb6 | ||
|
|
c1c9f882a6 | ||
|
|
1bcf26f656 | ||
|
|
7c2466da5e | ||
|
|
7dc78a1f6f | ||
|
|
88d024023b | ||
|
|
626aacf982 | ||
|
|
d5855c45a6 | ||
|
|
793bff9f27 | ||
|
|
88ea68e72f | ||
|
|
35e40d2c55 | ||
|
|
c472b83409 | ||
|
|
52c26d235c | ||
|
|
ac54729012 | ||
|
|
0586034ef4 | ||
|
|
91790ba708 | ||
|
|
d8ab6c0b50 | ||
|
|
b600a21a2b | ||
|
|
4f9d1278f7 | ||
|
|
15aa93f5f9 | ||
|
|
c7798092d8 | ||
|
|
5560593aaa | ||
|
|
66639e651d | ||
|
|
8e42d5ccdb | ||
|
|
5c62594087 | ||
|
|
26b6c48657 | ||
|
|
0290aba982 | ||
|
|
0bafc4e4f5 | ||
|
|
9a36f94279 | ||
|
|
1d8e66179e | ||
|
|
fda6d16d8e | ||
|
|
c4737916df | ||
|
|
919465cdbb | ||
|
|
de3730fa4f | ||
|
|
aff26fdd46 | ||
|
|
3c0edf06af | ||
|
|
cb8939db88 | ||
|
|
bf4b3213c4 | ||
|
|
633d7c52c4 | ||
|
|
0401cb92aa | ||
|
|
bff6c668a0 | ||
|
|
ee87e65763 | ||
|
|
f165a0b827 | ||
|
|
f7426dc8ce | ||
|
|
6114039f7e | ||
|
|
da414debe1 | ||
|
|
11f5541558 | ||
|
|
1bc155d684 | ||
|
|
335231060e | ||
|
|
0fdf64440f | ||
|
|
a984fb33dc | ||
|
|
41b1ec96c9 | ||
|
|
df83a61d6f | ||
|
|
d289f1fd13 | ||
|
|
aea4e961aa | ||
|
|
c554b73d48 | ||
|
|
b519bff3d6 | ||
|
|
8381104302 | ||
|
|
5ef7c6a1a2 | ||
|
|
6d7a81850c | ||
|
|
6e5d5fcb95 | ||
|
|
004fef6729 | ||
|
|
0bec5a6405 | ||
|
|
60b091ff1c | ||
|
|
bee1a5cb2d | ||
|
|
bb2d3dd5b1 | ||
|
|
bf8aad04c7 | ||
|
|
4306294a72 | ||
|
|
10f3722fe3 | ||
|
|
c1af9ca44a | ||
|
|
5b230c74f0 | ||
|
|
5cebb4e61a | ||
|
|
bd9d1e2244 | ||
|
|
9bdaa05f00 | ||
|
|
750ad0c902 | ||
|
|
a9c16838e6 | ||
|
|
d5065ab6d9 | ||
|
|
9ebb3ef532 | ||
|
|
aeda72f13e | ||
|
|
83aa9041cb | ||
|
|
d51913509d | ||
|
|
5106f28ba5 | ||
|
|
0c55c6eaab | ||
|
|
b0edbd19c8 | ||
|
|
7630db79b7 | ||
|
|
55a7b82567 | ||
|
|
b5cb46918a | ||
|
|
a793ece1a5 | ||
|
|
0f6e4b641a | ||
|
|
5ac5fab0c6 | ||
|
|
8030a8a235 | ||
|
|
d98426cad3 | ||
|
|
06034a8fc4 | ||
|
|
1ee9f9bb51 | ||
|
|
4b99d1405e | ||
|
|
8480e52195 | ||
|
|
243e65a992 | ||
|
|
b82304a233 | ||
|
|
f7a4ea9735 | ||
|
|
33d1a84ecd | ||
|
|
f4a071ee05 | ||
|
|
e26ba0f9d0 | ||
|
|
b4e2a12375 | ||
|
|
5e7aacd31a | ||
|
|
00718df49e | ||
|
|
bb9025ab07 | ||
|
|
867f3908ed | ||
|
|
30e1ecac39 | ||
|
|
7eb2abe9b2 | ||
|
|
a5ac8fa035 | ||
|
|
dd705de155 | ||
|
|
b15cdec701 | ||
|
|
a99a36b5cc | ||
|
|
e0b0e3d781 | ||
|
|
98a4834d4f | ||
|
|
32b135dbaf | ||
|
|
0fc8d12a06 | ||
|
|
3c2bdab101 | ||
|
|
8b5d7ae3ed | ||
|
|
51949f4fbf | ||
|
|
6013cd2329 | ||
|
|
eba28ade48 | ||
|
|
44af1ddc8a | ||
|
|
63c0d09df8 | ||
|
|
f305633d94 | ||
|
|
13155f8591 | ||
|
|
f2ac97aa62 | ||
|
|
18eb0027a1 | ||
|
|
9e2803fcfb | ||
|
|
705e30b6e0 | ||
|
|
f1260911ea | ||
|
|
076ff63dbe | ||
|
|
899092b4d2 | ||
|
|
c2c3a28aab | ||
|
|
25c0db502e | ||
|
|
6dcbe45a53 | ||
|
|
e2b46f25ff | ||
|
|
981182be46 | ||
|
|
ad164ebd5e | ||
|
|
cacdad8826 | ||
|
|
77e5142a7c | ||
|
|
613081728d | ||
|
|
23e77dfec1 | ||
|
|
6e273ae2a3 | ||
|
|
4061094988 | ||
|
|
82b185e27f | ||
|
|
27dc261639 | ||
|
|
7e45fecf19 | ||
|
|
1a5053380b | ||
|
|
408665c62d | ||
|
|
65efee2048 | ||
|
|
3faa66a1fc | ||
|
|
9dafe4f704 | ||
|
|
356eaf1713 | ||
|
|
f8584f1537 | ||
|
|
6ad6cb34b0 | ||
|
|
32b27cd780 | ||
|
|
0344a1e8c9 | ||
|
|
0515271c12 | ||
|
|
5ae8d54ce0 | ||
|
|
33c406ce49 | ||
|
|
3b660ddbd0 | ||
|
|
3132728a27 | ||
|
|
7063128342 | ||
|
|
2187775462 | ||
|
|
18adcd1004 | ||
|
|
b0656d1e38 | ||
|
|
38e66047e0 | ||
|
|
c24f049dac | ||
|
|
53d13c8172 | ||
|
|
0727c6e437 | ||
|
|
8328d20150 | ||
|
|
afe6a3bf57 | ||
|
|
d920632cbd | ||
|
|
5c456fd4d5 | ||
|
|
38c247e350 | ||
|
|
0c8f72124a | ||
|
|
80ed6b1525 | ||
|
|
4424b3f208 | ||
|
|
2c75abce09 | ||
|
|
4e15eb197f | ||
|
|
a7544b4f8c | ||
|
|
d126aad172 | ||
|
|
acc5c0de50 | ||
|
|
3391da111d | ||
|
|
e37ce96956 | ||
|
|
c51831c975 | ||
|
|
180aa39de4 | ||
|
|
3bd780782e | ||
|
|
f9ba2f79c2 | ||
|
|
d9493de2be | ||
|
|
bc9a623742 | ||
|
|
532edbf274 | ||
|
|
1585692328 | ||
|
|
083f565b12 | ||
|
|
f7f7438c9e | ||
|
|
19934a93bb | ||
|
|
577cfe5bdc | ||
|
|
43ac6afae1 | ||
|
|
8cc11703d3 | ||
|
|
4f7a116378 | ||
|
|
513793d9ce | ||
|
|
67f32b6734 | ||
|
|
66813d67fe | ||
|
|
a38691ed53 | ||
|
|
deeefdcfbf | ||
|
|
db292511b1 | ||
|
|
1a5334c1ce | ||
|
|
11002abe39 | ||
|
|
d922dcb062 | ||
|
|
6fcaa18e86 | ||
|
|
7664c941dd | ||
|
|
6f5cb528c6 | ||
|
|
ebb78922f0 | ||
|
|
2285fe9f1c | ||
|
|
38ba8625d8 | ||
|
|
ab5681c7ad | ||
|
|
f66dcb9267 | ||
|
|
1b6cfbac77 | ||
|
|
4c27e788ea | ||
|
|
769da0b052 | ||
|
|
6b60c86300 | ||
|
|
30c1b5e8c7 | ||
|
|
10af9b6f99 | ||
|
|
aa8c066f2d | ||
|
|
b913b74449 | ||
|
|
b71adce50b | ||
|
|
0fbb44c701 | ||
|
|
de335e8637 | ||
|
|
2999f63a4c | ||
|
|
2abc5e6f0b | ||
|
|
639de4321e | ||
|
|
b3c461afdd | ||
|
|
7d154800a0 | ||
|
|
b48ed0399e | ||
|
|
c5d6e7d74a | ||
|
|
e82f915363 | ||
|
|
3128e9ce76 | ||
|
|
bc0e86757c | ||
|
|
fec99916c2 | ||
|
|
3b5d059b11 | ||
|
|
c3fe2acc8a | ||
|
|
4d002c412b | ||
|
|
46d152b5f1 | ||
|
|
25fa81ebbc | ||
|
|
7c2de3c360 | ||
|
|
3a3b187cd0 | ||
|
|
3226bbe083 | ||
|
|
a1e4e0e6c9 | ||
|
|
b3aa8b893b | ||
|
|
f057139634 | ||
|
|
71a2b11ab4 | ||
|
|
587254a0e7 | ||
|
|
9f4de66f3c | ||
|
|
b0d8908724 | ||
|
|
15c22d98c6 | ||
|
|
3105ae0edc | ||
|
|
11a89f06c1 | ||
|
|
9cbe24e740 | ||
|
|
bfbed13b8f | ||
|
|
2268de6321 | ||
|
|
dd99aa7fcd | ||
|
|
be436bb706 | ||
|
|
bd48726f44 | ||
|
|
10bea83f98 | ||
|
|
8122b4fb84 | ||
|
|
3ae57fb2d8 | ||
|
|
6dc3eecca4 | ||
|
|
9d1d732154 | ||
|
|
8a117415b7 | ||
|
|
d36623ebc9 | ||
|
|
94a3ae3696 | ||
|
|
2836a28988 | ||
|
|
946d7dc89e | ||
|
|
af6300f18b | ||
|
|
905cb4b18e | ||
|
|
305ed09547 | ||
|
|
643356bad3 | ||
|
|
e458675627 | ||
|
|
91e3853692 | ||
|
|
5f0876a136 | ||
|
|
3a38127fb4 | ||
|
|
f3b6070235 | ||
|
|
5e6e78eb9e | ||
|
|
9b66a1d1a8 | ||
|
|
e954d0d7bc | ||
|
|
dab2df7e79 | ||
|
|
bc40e22008 | ||
|
|
eef262c398 | ||
|
|
8eab6e14db | ||
|
|
ded33a110a | ||
|
|
e448a7602a | ||
|
|
4c22215ca5 | ||
|
|
4f501abb72 | ||
|
|
b2dcc38982 | ||
|
|
11b719955b | ||
|
|
d563ac63db | ||
|
|
6d826064c6 | ||
|
|
d30b9d6518 | ||
|
|
8da3364d0f | ||
|
|
07c372b7f5 | ||
|
|
7e01f38253 | ||
|
|
ba637009a7 | ||
|
|
da7388e510 | ||
|
|
3ec88fc896 | ||
|
|
1c9381b2bd | ||
|
|
06349b8d5b | ||
|
|
6dc7dc6ad2 | ||
|
|
f981a15ec3 | ||
|
|
8b648c0301 | ||
|
|
83ce09075b | ||
|
|
168dfb9f6b | ||
|
|
9b8961c23d | ||
|
|
89bca42ee6 | ||
|
|
07d2a43a17 | ||
|
|
c84f2afd09 | ||
|
|
df4dbaecc8 | ||
|
|
d9bf03cefe | ||
|
|
39223e8d89 | ||
|
|
67925e18b2 | ||
|
|
89ad65513d | ||
|
|
90166ddfa3 | ||
|
|
0981b23faf | ||
|
|
664f3b4d87 | ||
|
|
dc97b91a4e | ||
|
|
d310272d19 | ||
|
|
f1be3f01e1 | ||
|
|
c57b6e1d73 | ||
|
|
a938dc45f0 | ||
|
|
bb139744a1 | ||
|
|
3aa3e09552 | ||
|
|
74abfd21b8 | ||
|
|
e703817ba2 | ||
|
|
80dd1e457b | ||
|
|
ea9f8d3ab2 | ||
|
|
fa222bdf12 | ||
|
|
45b360dabd | ||
|
|
5923399359 | ||
|
|
f4600f3e90 | ||
|
|
f883837685 | ||
|
|
b58bc409f0 | ||
|
|
e893e539bb | ||
|
|
90294fbb5d | ||
|
|
ae65f222bc | ||
|
|
1b9813fb4c | ||
|
|
b708b5ae41 | ||
|
|
df136fa915 | ||
|
|
f8329f5b8d | ||
|
|
21141090de | ||
|
|
c0d9740a7d | ||
|
|
afcf630443 | ||
|
|
1fe2c9826a | ||
|
|
7272b80a3f | ||
|
|
92114b7368 | ||
|
|
f39d3e7eed | ||
|
|
cbe0d27a5e | ||
|
|
cd39699467 | ||
|
|
b3ea67aacf | ||
|
|
db4ed9797c | ||
|
|
1ea7d7d685 | ||
|
|
2df725b57a | ||
|
|
74e6648249 | ||
|
|
1026350d9c | ||
|
|
98fb87874d | ||
|
|
41fc3afdc1 | ||
|
|
83dbf46ba4 | ||
|
|
0b2e35bdde | ||
|
|
d90a7331c9 | ||
|
|
264e64a996 | ||
|
|
8915915c47 | ||
|
|
951ed787fa | ||
|
|
64ef6b0c22 | ||
|
|
ef18377b3c | ||
|
|
5904b6fded | ||
|
|
f4401e77bb | ||
|
|
efa5455a7b | ||
|
|
619c8d9e72 | ||
|
|
bdf89ac288 | ||
|
|
debd3c8185 | ||
|
|
f81a3ae8e7 | ||
|
|
7d4e9894c3 | ||
|
|
4bf22d8a60 | ||
|
|
8be4971a23 | ||
|
|
359e916b73 | ||
|
|
68058f3e41 | ||
|
|
0c6fa3e634 | ||
|
|
0fa25c6335 | ||
|
|
5684479f1d | ||
|
|
2d1603601c | ||
|
|
f5394b2210 | ||
|
|
833db5df06 | ||
|
|
525ac7e980 | ||
|
|
44a747c80a | ||
|
|
2056e7f40a | ||
|
|
9b6c1ad364 | ||
|
|
34987bcacb | ||
|
|
b62c11222a | ||
|
|
b3cee3ace3 | ||
|
|
222c054c95 | ||
|
|
46f18a2491 | ||
|
|
f2ca8e2753 | ||
|
|
b0d243c378 | ||
|
|
6161fb86c8 | ||
|
|
b09cc91fe5 | ||
|
|
ef1638cbb3 | ||
|
|
00ef8743f2 | ||
|
|
68222659e3 | ||
|
|
69420a4bba | ||
|
|
0161bbaeb1 | ||
|
|
948dbfe3cc | ||
|
|
338ba8b189 | ||
|
|
ca4655b441 | ||
|
|
bf37499428 | ||
|
|
0b94b57e2a | ||
|
|
fc40aead98 | ||
|
|
7d7f934e6a | ||
|
|
d5fbf4d622 | ||
|
|
e4f6c919dc | ||
|
|
4d806ff2b1 | ||
|
|
bf8f12274f | ||
|
|
f4f438d9fe | ||
|
|
2434f373be | ||
|
|
2bb2061f97 | ||
|
|
2c011a5c2a | ||
|
|
f66b0ccea1 | ||
|
|
665dd8447d | ||
|
|
1b61ce31e6 | ||
|
|
ef4d960698 | ||
|
|
b6d557b632 | ||
|
|
b700bd356c | ||
|
|
620dd7d3ef | ||
|
|
6575121902 | ||
|
|
7c1755a0dc | ||
|
|
8ad301a666 | ||
|
|
ae24cd4939 | ||
|
|
7152e1845e | ||
|
|
96c1dd4081 | ||
|
|
87c7b3a663 | ||
|
|
c1be46a539 | ||
|
|
4655e0018b | ||
|
|
da5ba2e3be | ||
|
|
aaf95f565f | ||
|
|
f32b984e77 | ||
|
|
548aa4c7cd | ||
|
|
0ccceaac77 | ||
|
|
70f534f1d8 | ||
|
|
61fe95b300 | ||
|
|
915e0e8613 | ||
|
|
aace2580da | ||
|
|
3d36905664 | ||
|
|
0d671423da | ||
|
|
aebfcb9437 | ||
|
|
be7ef7beb1 | ||
|
|
d77ed0c5cc | ||
|
|
e57e7bcec5 | ||
|
|
a637842ce4 | ||
|
|
fc54ec49af | ||
|
|
5c43d8510a | ||
|
|
83f84ded8d | ||
|
|
5658da34a2 | ||
|
|
38e8ef6535 | ||
|
|
8c89b06238 | ||
|
|
d85c021305 | ||
|
|
83bb18df03 | ||
|
|
93105a3e89 | ||
|
|
ba3b899115 | ||
|
|
fcfbc1d1da | ||
|
|
72486b448c | ||
|
|
7dea1b7870 | ||
|
|
4de2c496c9 | ||
|
|
9e1393a392 | ||
|
|
0901690ed6 | ||
|
|
95303648cc | ||
|
|
1dbb08c045 | ||
|
|
00a7d9a180 | ||
|
|
6c549dc086 | ||
|
|
dc368e326a | ||
|
|
e42188a627 | ||
|
|
7a6a337eff | ||
|
|
3907344884 |
@@ -1,83 +0,0 @@
|
|||||||
# Claude Context: Detaching Tauri from Yaak
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
crates/ # Core crates - should NOT depend on Tauri
|
|
||||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
|
||||||
crates-cli/ # CLI crate (yaak-cli)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Completed Work
|
|
||||||
|
|
||||||
### 1. Folder Restructure
|
|
||||||
|
|
||||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
|
||||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
|
||||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
|
||||||
|
|
||||||
### 2. Decoupled Crates (no longer depend on Tauri)
|
|
||||||
|
|
||||||
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
|
||||||
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
|
||||||
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
|
||||||
- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
|
|
||||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
|
||||||
|
|
||||||
### 3. CLI Implementation
|
|
||||||
|
|
||||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
|
||||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
|
||||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
|
||||||
|
|
||||||
## Remaining Work
|
|
||||||
|
|
||||||
### Crates Still Depending on Tauri (in `crates/`)
|
|
||||||
|
|
||||||
1. **yaak-git** (3 files) - Moderate complexity
|
|
||||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
|
||||||
3. **yaak-sync** (4 files) - Moderate complexity
|
|
||||||
4. **yaak-ws** (5 files) - Moderate complexity
|
|
||||||
|
|
||||||
### Pattern for Decoupling
|
|
||||||
|
|
||||||
1. Remove Tauri plugin `init()` function from the crate
|
|
||||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
|
||||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
|
||||||
4. Initialize managers in yaak-app's `.setup()` block
|
|
||||||
5. Remove `tauri` from Cargo.toml dependencies
|
|
||||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
|
||||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
|
||||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
|
||||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
|
||||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
|
||||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
|
||||||
|
|
||||||
## Git Branch
|
|
||||||
|
|
||||||
Working on `detach-tauri` branch.
|
|
||||||
|
|
||||||
## Recent Commits
|
|
||||||
|
|
||||||
```
|
|
||||||
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
|
||||||
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
|
||||||
481e0273 Remove Tauri dependencies from yaak-http and yaak-common
|
|
||||||
10568ac3 Add HTTP request sending to yaak-cli
|
|
||||||
bcb7d600 Add yaak-cli stub with basic database access
|
|
||||||
e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
|
||||||
- Run `npm run client:dev` to test the Tauri app still works
|
|
||||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
description: Generate formatted release notes for Yaak releases
|
|
||||||
allowed-tools: Bash(git tag:*)
|
|
||||||
---
|
|
||||||
|
|
||||||
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
|
|
||||||
|
|
||||||
## What to do
|
|
||||||
|
|
||||||
1. Identifies the version tag and previous version
|
|
||||||
2. Retrieves all commits between versions
|
|
||||||
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
|
|
||||||
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
|
|
||||||
3. Fetches PR descriptions for linked issues to find:
|
|
||||||
- Feedback URLs (feedback.yaak.app)
|
|
||||||
- Additional context and descriptions
|
|
||||||
- Installation links for plugins
|
|
||||||
4. Formats the release notes using the standard Yaak format:
|
|
||||||
- Changelog badge at the top
|
|
||||||
- Bulleted list of changes with PR links
|
|
||||||
- Feedback links where available
|
|
||||||
- Full changelog comparison link at the bottom
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
The skill generates markdown-formatted release notes following this structure:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[](https://yaak.app/changelog/VERSION)
|
|
||||||
|
|
||||||
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
|
|
||||||
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
|
|
||||||
- A simple item that doesn't have a feedback or PR link
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
|
||||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
|
||||||
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
|
||||||
|
|
||||||
## After Generating Release Notes
|
|
||||||
|
|
||||||
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Project Rules
|
|
||||||
|
|
||||||
## General Development
|
|
||||||
|
|
||||||
- **NEVER** commit or push without explicit confirmation
|
|
||||||
|
|
||||||
## Build and Lint
|
|
||||||
|
|
||||||
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
|
|
||||||
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
|
|
||||||
|
|
||||||
## Plugin System
|
|
||||||
|
|
||||||
### Backend Constraints
|
|
||||||
|
|
||||||
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
|
|
||||||
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
|
|
||||||
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
|
|
||||||
|
|
||||||
### MCP Server
|
|
||||||
|
|
||||||
- MCP server has **no active window context** - cannot call `window.workspaceId()`
|
|
||||||
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
|
|
||||||
|
|
||||||
## Rust Type Generation
|
|
||||||
|
|
||||||
- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
name: release-generate-release-notes
|
|
||||||
description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Generate Release Notes
|
|
||||||
|
|
||||||
Generate formatted markdown release notes for a Yaak tag.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Determine target tag.
|
|
||||||
2. Determine previous comparable tag:
|
|
||||||
- Beta tag: compare against previous beta (if the root version is the same) or stable tag.
|
|
||||||
- Stable tag: compare against previous stable tag.
|
|
||||||
3. Collect commits in range:
|
|
||||||
- `git log --oneline <prev_tag>..<target_tag>`
|
|
||||||
4. For linked PRs, fetch metadata:
|
|
||||||
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
|
|
||||||
5. Extract useful details:
|
|
||||||
- Feedback URLs (`feedback.yaak.app`)
|
|
||||||
- Plugin install links or other notable context
|
|
||||||
6. Format notes using Yaak style:
|
|
||||||
- Changelog badge at top
|
|
||||||
- Bulleted items with PR links where available
|
|
||||||
- Feedback links where available
|
|
||||||
- Full changelog compare link at bottom
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
- Wrap final notes in a markdown code fence.
|
|
||||||
- Keep a blank line before and after the code fence.
|
|
||||||
- Output the markdown code block last.
|
|
||||||
- Do not append `by @gschier` for PRs authored by `@gschier`.
|
|
||||||
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
|
||||||
|
|
||||||
## Release Creation Prompt
|
|
||||||
|
|
||||||
After producing notes, ask whether to create a draft GitHub release.
|
|
||||||
|
|
||||||
If confirmed and release does not yet exist, run:
|
|
||||||
|
|
||||||
`gh release create <tag> --draft --prerelease --title "Release <version_without_v>" --notes '<release notes>'`
|
|
||||||
|
|
||||||
If a draft release for the tag already exists, update it instead:
|
|
||||||
|
|
||||||
`gh release edit <tag> --title "Release <version_without_v>" --notes-file <path_to_notes>`
|
|
||||||
|
|
||||||
Use title format `Release <version_without_v>`, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.
|
|
||||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.prettierrc.cjs
|
||||||
|
.eslintrc.cjs
|
||||||
|
env.d.ts
|
||||||
37
.eslintrc.cjs
Normal file
37
.eslintrc.cjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
module.exports = {
|
||||||
|
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/**/*", "plugins/**/*"],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect"
|
||||||
|
},
|
||||||
|
"import/resolver": {
|
||||||
|
node: {
|
||||||
|
paths: ["src-web"],
|
||||||
|
extensions: [".ts", ".tsx"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"jsx-a11y/no-autofocus": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"import/no-unresolved": "off",
|
||||||
|
"@typescript-eslint/consistent-type-imports": ["error", {
|
||||||
|
prefer: "type-imports",
|
||||||
|
disallowTypeAnnotations: true,
|
||||||
|
fixStyle: "separate-type-imports"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
7
.gitattributes
vendored
7
.gitattributes
vendored
@@ -1,7 +0,0 @@
|
|||||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
|
||||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
|
||||||
**/bindings/* linguist-generated=true
|
|
||||||
crates/yaak-templates/pkg/* linguist-generated=true
|
|
||||||
|
|
||||||
# Ensure consistent line endings for test files that check exact content
|
|
||||||
crates/yaak-http/tests/test.txt text eol=lf
|
|
||||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: gschier
|
|
||||||
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ""
|
|
||||||
labels: ""
|
|
||||||
assignees: ""
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Bugs, Feedback, Feature Requests, and Questions
|
|
||||||
url: https://feedback.yaak.app
|
|
||||||
about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable."
|
|
||||||
19
.github/pull_request_template.md
vendored
19
.github/pull_request_template.md
vendored
@@ -1,19 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
<!-- Describe the bug and the fix in 1-3 sentences. -->
|
|
||||||
|
|
||||||
## Submission
|
|
||||||
|
|
||||||
- [ ] This PR is a bug fix or small-scope improvement.
|
|
||||||
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
|
|
||||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
|
||||||
- [ ] I tested this change locally.
|
|
||||||
- [ ] I added or updated tests when reasonable.
|
|
||||||
|
|
||||||
Approved feedback item (required if not a bug fix or small-scope improvement):
|
|
||||||
|
|
||||||
<!-- https://yaak.app/feedback/... -->
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
<!-- Link related issues, discussions, or feedback items. -->
|
|
||||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
name: Lint and Test
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Lint/Test
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: voidzero-dev/setup-vp@v1
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: true
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: ci
|
|
||||||
cache-on-failure: true
|
|
||||||
|
|
||||||
- run: vp install
|
|
||||||
- run: npm run bootstrap
|
|
||||||
- run: npm run lint
|
|
||||||
- name: Run JS Tests
|
|
||||||
run: vp test
|
|
||||||
- name: Run Rust Tests
|
|
||||||
run: cargo test --all
|
|
||||||
49
.github/workflows/claude.yml
vendored
49
.github/workflows/claude.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
52
.github/workflows/flathub.yml
vendored
52
.github/workflows/flathub.yml
vendored
@@ -1,52 +0,0 @@
|
|||||||
name: Update Flathub
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-flathub:
|
|
||||||
name: Update Flathub manifest
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Only run for stable releases (skip betas/pre-releases)
|
|
||||||
if: ${{ !github.event.release.prerelease }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout app repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Checkout Flathub repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: flathub/app.yaak.Yaak
|
|
||||||
token: ${{ secrets.FLATHUB_TOKEN }}
|
|
||||||
path: flathub-repo
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Install source generators
|
|
||||||
run: |
|
|
||||||
pip install flatpak-node-generator tomlkit aiohttp
|
|
||||||
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
|
|
||||||
|
|
||||||
- name: Run update-manifest.sh
|
|
||||||
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
|
|
||||||
|
|
||||||
- name: Commit and push to Flathub
|
|
||||||
working-directory: flathub-repo
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet && echo "No changes to commit" && exit 0
|
|
||||||
git commit -m "Update to ${{ github.event.release.tag_name }}"
|
|
||||||
git push
|
|
||||||
59
.github/workflows/release-api-npm.yml
vendored
59
.github/workflows/release-api-npm.yml
vendored
@@ -1,59 +0,0 @@
|
|||||||
name: Release API to NPM
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [yaak-api-*]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: API version to publish (for example 0.9.0 or v0.9.0)
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-npm:
|
|
||||||
name: Publish @yaakapp/api
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Set @yaakapp/api version
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-api-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "Preparing @yaakapp/api version: $VERSION"
|
|
||||||
cd packages/plugin-runtime-types
|
|
||||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
|
||||||
|
|
||||||
- name: Build @yaakapp/api
|
|
||||||
working-directory: packages/plugin-runtime-types
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/api
|
|
||||||
working-directory: packages/plugin-runtime-types
|
|
||||||
run: npm publish --provenance --access public
|
|
||||||
185
.github/workflows/release-app.yml
vendored
185
.github/workflows/release-app.yml
vendored
@@ -1,185 +0,0 @@
|
|||||||
name: Release App Artifacts
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [v*]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-artifacts:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
name: Build
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
|
||||||
args: "--target aarch64-apple-darwin"
|
|
||||||
yaak_arch: "arm64"
|
|
||||||
os: "macos"
|
|
||||||
targets: "aarch64-apple-darwin"
|
|
||||||
- platform: "macos-latest" # for Intel-based Macs.
|
|
||||||
args: "--target x86_64-apple-darwin"
|
|
||||||
yaak_arch: "x64"
|
|
||||||
os: "macos"
|
|
||||||
targets: "x86_64-apple-darwin"
|
|
||||||
- platform: "ubuntu-22.04"
|
|
||||||
args: ""
|
|
||||||
yaak_arch: "x64"
|
|
||||||
os: "ubuntu"
|
|
||||||
targets: ""
|
|
||||||
- platform: "ubuntu-22.04-arm"
|
|
||||||
args: ""
|
|
||||||
yaak_arch: "arm64"
|
|
||||||
os: "ubuntu"
|
|
||||||
targets: ""
|
|
||||||
- platform: "windows-latest"
|
|
||||||
args: ""
|
|
||||||
yaak_arch: "x64"
|
|
||||||
os: "windows"
|
|
||||||
targets: ""
|
|
||||||
# Windows ARM64
|
|
||||||
- platform: "windows-latest"
|
|
||||||
args: "--target aarch64-pc-windows-msvc"
|
|
||||||
yaak_arch: "arm64"
|
|
||||||
os: "windows"
|
|
||||||
targets: "aarch64-pc-windows-msvc"
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
timeout-minutes: 40
|
|
||||||
steps:
|
|
||||||
- name: Checkout yaakapp/app
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Vite+
|
|
||||||
uses: voidzero-dev/setup-vp@v1
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.targets }}
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: ci
|
|
||||||
cache-on-failure: true
|
|
||||||
|
|
||||||
- name: install dependencies (Linux only)
|
|
||||||
if: matrix.os == 'ubuntu'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
|
||||||
|
|
||||||
- name: Install Protoc for plugin-runtime
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install trusted-signing-cli (Windows only)
|
|
||||||
if: matrix.os == 'windows'
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$dir = "$env:USERPROFILE\trusted-signing"
|
|
||||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
||||||
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
|
|
||||||
$exe = Join-Path $dir "trusted-signing-cli.exe"
|
|
||||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
|
||||||
echo $dir >> $env:GITHUB_PATH
|
|
||||||
& $exe --version
|
|
||||||
|
|
||||||
- run: vp install
|
|
||||||
- run: npm run bootstrap
|
|
||||||
env:
|
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
|
||||||
- run: npm run lint
|
|
||||||
- name: Run JS Tests
|
|
||||||
run: vp test
|
|
||||||
- name: Run Rust Tests
|
|
||||||
run: cargo test --all --exclude yaak-cli
|
|
||||||
|
|
||||||
- name: Set version
|
|
||||||
run: npm run replace-version
|
|
||||||
env:
|
|
||||||
YAAK_VERSION: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Sign vendored binaries (macOS only)
|
|
||||||
if: matrix.os == 'macos'
|
|
||||||
env:
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
# Create keychain
|
|
||||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
|
||||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
|
|
||||||
# Import certificate
|
|
||||||
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
|
|
||||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
|
||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
|
||||||
|
|
||||||
# Sign vendored binaries with hardened runtime and their specific entitlements
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
|
||||||
|
|
||||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
# Apple signing stuff
|
|
||||||
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
|
|
||||||
|
|
||||||
# Windows signing stuff
|
|
||||||
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
|
|
||||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
|
||||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
|
||||||
with:
|
|
||||||
tagName: "v__VERSION__"
|
|
||||||
releaseName: "Release __VERSION__"
|
|
||||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
|
||||||
releaseDraft: true
|
|
||||||
prerelease: true
|
|
||||||
projectPath: ./crates-tauri/yaak-app-client
|
|
||||||
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
|
|
||||||
|
|
||||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
|
||||||
- name: Build and upload machine-wide installer (Windows only)
|
|
||||||
if: matrix.os == 'windows'
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
|
||||||
Push-Location crates-tauri/yaak-app-client
|
|
||||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
|
||||||
Pop-Location
|
|
||||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
|
||||||
$setupSig = "$($setup.FullName).sig"
|
|
||||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
|
||||||
$destSig = "$dest.sig"
|
|
||||||
Copy-Item $setup.FullName $dest
|
|
||||||
Copy-Item $setupSig $destSig
|
|
||||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
|
||||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
|
||||||
218
.github/workflows/release-cli-npm.yml
vendored
218
.github/workflows/release-cli-npm.yml
vendored
@@ -1,218 +0,0 @@
|
|||||||
name: Release CLI to NPM
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [yaak-cli-*]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: CLI version to publish (for example 0.4.0 or v0.4.0)
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare-vendored-assets:
|
|
||||||
name: Prepare vendored plugin assets
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build plugin assets
|
|
||||||
env:
|
|
||||||
SKIP_WASM_BUILD: "1"
|
|
||||||
run: |
|
|
||||||
npm run build
|
|
||||||
npm run vendor:vendor-plugins
|
|
||||||
|
|
||||||
- name: Upload vendored assets
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vendored-assets
|
|
||||||
path: |
|
|
||||||
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
|
|
||||||
crates-tauri/yaak-app-client/vendored/plugins
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-binaries:
|
|
||||||
name: Build ${{ matrix.pkg }}
|
|
||||||
needs: prepare-vendored-assets
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- pkg: cli-darwin-arm64
|
|
||||||
runner: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-darwin-x64
|
|
||||||
runner: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-linux-arm64
|
|
||||||
runner: ubuntu-22.04-arm
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-linux-x64
|
|
||||||
runner: ubuntu-22.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-win32-arm64
|
|
||||||
runner: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
binary: yaak.exe
|
|
||||||
- pkg: cli-win32-x64
|
|
||||||
runner: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
binary: yaak.exe
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: release-cli-npm
|
|
||||||
cache-on-failure: true
|
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
|
||||||
if: startsWith(matrix.runner, 'ubuntu')
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y pkg-config libdbus-1-dev
|
|
||||||
|
|
||||||
- name: Download vendored assets
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vendored-assets
|
|
||||||
path: crates-tauri/yaak-app-client/vendored
|
|
||||||
|
|
||||||
- name: Set CLI build version
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "Building yaak version: $VERSION"
|
|
||||||
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build yaak
|
|
||||||
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Stage binary artifact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "npm/dist/${{ matrix.pkg }}"
|
|
||||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.pkg }}
|
|
||||||
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
publish-npm:
|
|
||||||
name: Publish @yaakapp/cli packages
|
|
||||||
needs: build-binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Download binary artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
pattern: cli-*
|
|
||||||
path: npm/dist
|
|
||||||
merge-multiple: false
|
|
||||||
|
|
||||||
- name: Prepare npm packages
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
if [[ "$VERSION" == *-* ]]; then
|
|
||||||
PRERELEASE="${VERSION#*-}"
|
|
||||||
NPM_TAG="${PRERELEASE%%.*}"
|
|
||||||
else
|
|
||||||
NPM_TAG="latest"
|
|
||||||
fi
|
|
||||||
echo "Preparing CLI npm packages for version: $VERSION"
|
|
||||||
echo "Publishing with npm dist-tag: $NPM_TAG"
|
|
||||||
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
|
|
||||||
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-darwin-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-darwin-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-darwin-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-darwin-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-linux-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-linux-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-linux-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-linux-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-win32-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-win32-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-win32-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-win32-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli
|
|
||||||
72
.github/workflows/release.yml
vendored
Normal file
72
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Generate Artifacts
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [ v* ]
|
||||||
|
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-artifacts:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
name: Build
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'macos-latest' # for Arm based macs (M1 and above).
|
||||||
|
args: '--target aarch64-apple-darwin'
|
||||||
|
- platform: 'macos-latest' # for Intel based macs.
|
||||||
|
args: '--target x86_64-apple-darwin'
|
||||||
|
- platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
|
||||||
|
args: ''
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
args: ''
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: setup node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
|
||||||
|
- name: install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
||||||
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
|
||||||
|
# You can remove the one that doesn't apply to your app to speed up the workflow a bit.
|
||||||
|
|
||||||
|
- 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_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
with:
|
||||||
|
tagName: 'v__VERSION__'
|
||||||
|
releaseName: 'Release __VERSION__'
|
||||||
|
releaseBody: 'https://yaak.app/changelog/__VERSION__'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: ${{ matrix.args }}
|
||||||
44
.github/workflows/sponsors.yml
vendored
44
.github/workflows/sponsors.yml
vendored
@@ -1,44 +0,0 @@
|
|||||||
name: Generate Sponsors README
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: 30 15 * * 0-6
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎️
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Generate Sponsors
|
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
|
||||||
file: "README.md"
|
|
||||||
maximum: 1999
|
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a> '
|
|
||||||
active-only: false
|
|
||||||
include-private: true
|
|
||||||
marker: "sponsors-base"
|
|
||||||
|
|
||||||
- name: Generate Sponsors
|
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
|
||||||
file: "README.md"
|
|
||||||
minimum: 2000
|
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a> '
|
|
||||||
active-only: false
|
|
||||||
include-private: true
|
|
||||||
marker: "sponsors-premium"
|
|
||||||
|
|
||||||
# ⚠️ Note: You can use any deployment step here to automatically push the README
|
|
||||||
# changes back to your branch.
|
|
||||||
- name: Commit Changes
|
|
||||||
uses: JamesIves/github-pages-deploy-action@v4
|
|
||||||
with:
|
|
||||||
branch: main
|
|
||||||
force: false
|
|
||||||
folder: "."
|
|
||||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -15,8 +15,6 @@ dist-ssr
|
|||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
@@ -25,36 +23,8 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.eslintcache
|
.eslintcache
|
||||||
out
|
|
||||||
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
|
|
||||||
.cargo
|
.cargo
|
||||||
|
|
||||||
.tmp
|
|
||||||
tmp
|
|
||||||
.zed
|
|
||||||
codebook.toml
|
|
||||||
target
|
|
||||||
|
|
||||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
|
||||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
|
||||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
|
||||||
|
|
||||||
# Tauri auto-generated permission files
|
|
||||||
**/permissions/autogenerated
|
|
||||||
**/permissions/schemas
|
|
||||||
|
|
||||||
# Flatpak build artifacts
|
|
||||||
flatpak-repo/
|
|
||||||
.flatpak-builder/
|
|
||||||
flatpak/flatpak-builder-tools/
|
|
||||||
flatpak/cargo-sources.json
|
|
||||||
flatpak/node-sources.json
|
|
||||||
|
|
||||||
# Local Codex desktop env state
|
|
||||||
.codex/environments/environment.toml
|
|
||||||
|
|
||||||
# Claude Code local settings
|
|
||||||
.claude/settings.local.json
|
|
||||||
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
_
|
||||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged
|
||||||
@@ -1 +0,0 @@
|
|||||||
24.14.0
|
|
||||||
2
.npmrc
2
.npmrc
@@ -1,2 +0,0 @@
|
|||||||
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
|
|
||||||
legacy-peer-deps=true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
**/bindings/**
|
|
||||||
**/routeTree.gen.ts
|
|
||||||
crates/yaak-templates/pkg/**
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/bindings/**",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"apps/yaak-client/routeTree.gen.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
.prettierrc.cjs
|
||||||
8
.prettierrc.cjs
Normal file
8
.prettierrc.cjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
17
.run/Build Desktop.run.xml
Normal file
17
.run/Build Desktop.run.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Build Desktop" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="npm run tauri build" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="false" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
13
.run/Dev Desktop.run.xml
Normal file
13
.run/Dev Desktop.run.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<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="start" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs>
|
||||||
|
</envs>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
5
.sqllsrc.json
Normal file
5
.sqllsrc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "yaak-dev",
|
||||||
|
"adapter": "sqlite3",
|
||||||
|
"filename": "src-tauri/db.sqlite"
|
||||||
|
}
|
||||||
1
.tauriignore
Normal file
1
.tauriignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
plugins
|
||||||
@@ -1 +0,0 @@
|
|||||||
node scripts/git-hooks/post-checkout.mjs "$@"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
vp lint
|
|
||||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"VoidZero.vite-plus-extension-pack"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
26
.vscode/launch.json
vendored
26
.vscode/launch.json
vendored
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Dev App",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "start"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Build App",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "start"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Bootstrap",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "bootstrap"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.formatOnSaveMode": "file",
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.oxc": "explicit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
|
|
||||||
- Do not commit, push, or tag without explicit approval
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Contributing to Yaak
|
|
||||||
|
|
||||||
Yaak accepts community pull requests for:
|
|
||||||
|
|
||||||
- Bug fixes
|
|
||||||
- Small-scope improvements directly tied to existing behavior
|
|
||||||
|
|
||||||
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
|
|
||||||
|
|
||||||
## Approval for Non-Bugfix Changes
|
|
||||||
|
|
||||||
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).
|
|
||||||
10872
Cargo.lock
generated
10872
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
91
Cargo.toml
91
Cargo.toml
@@ -1,91 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = [
|
|
||||||
"crates/yaak",
|
|
||||||
# Common/foundation crates
|
|
||||||
"crates/common/yaak-database",
|
|
||||||
"crates/common/yaak-rpc",
|
|
||||||
# Shared crates (no Tauri dependency)
|
|
||||||
"crates/yaak-core",
|
|
||||||
"crates/yaak-common",
|
|
||||||
"crates/yaak-crypto",
|
|
||||||
"crates/yaak-git",
|
|
||||||
"crates/yaak-grpc",
|
|
||||||
"crates/yaak-http",
|
|
||||||
"crates/yaak-models",
|
|
||||||
"crates/yaak-plugins",
|
|
||||||
"crates/yaak-sse",
|
|
||||||
"crates/yaak-sync",
|
|
||||||
"crates/yaak-templates",
|
|
||||||
"crates/yaak-tls",
|
|
||||||
"crates/yaak-ws",
|
|
||||||
"crates/yaak-api",
|
|
||||||
"crates/yaak-proxy",
|
|
||||||
# Proxy-specific crates
|
|
||||||
"crates-proxy/yaak-proxy-lib",
|
|
||||||
# CLI crates
|
|
||||||
"crates-cli/yaak-cli",
|
|
||||||
# Tauri-specific crates
|
|
||||||
"crates-tauri/yaak-app-client",
|
|
||||||
"crates-tauri/yaak-app-proxy",
|
|
||||||
"crates-tauri/yaak-fonts",
|
|
||||||
"crates-tauri/yaak-license",
|
|
||||||
"crates-tauri/yaak-mac-window",
|
|
||||||
"crates-tauri/yaak-tauri-utils",
|
|
||||||
"crates-tauri/yaak-window",
|
|
||||||
]
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
|
||||||
chrono = "0.4.42"
|
|
||||||
hex = "0.4.3"
|
|
||||||
keyring = "3.6.3"
|
|
||||||
log = "0.4.29"
|
|
||||||
reqwest = "0.12.20"
|
|
||||||
rustls = { version = "0.23.34", default-features = false }
|
|
||||||
rustls-platform-verifier = "0.6.2"
|
|
||||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
|
||||||
serde = "1.0.228"
|
|
||||||
serde_json = "1.0.145"
|
|
||||||
sha2 = "0.10.9"
|
|
||||||
tauri = "2.11.1"
|
|
||||||
tauri-plugin = "2.6.1"
|
|
||||||
tauri-plugin-dialog = "2.7.1"
|
|
||||||
tauri-plugin-shell = "2.3.5"
|
|
||||||
thiserror = "2.0.17"
|
|
||||||
tokio = "1.48.0"
|
|
||||||
ts-rs = "11.1.0"
|
|
||||||
|
|
||||||
# Internal crates - common/foundation
|
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
|
||||||
yaak = { path = "crates/yaak" }
|
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
|
||||||
yaak-sync = { path = "crates/yaak-sync" }
|
|
||||||
yaak-templates = { path = "crates/yaak-templates" }
|
|
||||||
yaak-tls = { path = "crates/yaak-tls" }
|
|
||||||
yaak-ws = { path = "crates/yaak-ws" }
|
|
||||||
yaak-api = { path = "crates/yaak-api" }
|
|
||||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
|
||||||
|
|
||||||
# Internal crates - proxy
|
|
||||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|
||||||
|
|
||||||
# Internal crates - Tauri-specific
|
|
||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = false
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Developer Setup
|
|
||||||
|
|
||||||
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
|
|
||||||
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
|
|
||||||
by a Node.js sidecar that communicates to the app over gRPC.
|
|
||||||
|
|
||||||
Because of the moving parts, there are a few setup steps required before development can
|
|
||||||
begin.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Make sure you have the following tools installed:
|
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
|
|
||||||
- [Rust](https://www.rust-lang.org/tools/install)
|
|
||||||
- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
|
|
||||||
|
|
||||||
Check the installations with the following commands:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
vp --version
|
|
||||||
rustc --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Install the NPM dependencies:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the `bootstrap` command to do some initial setup:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
npm run bootstrap
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run the App
|
|
||||||
|
|
||||||
After bootstrapping, start the app in development mode:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## SQLite Migrations
|
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
npm run migration
|
|
||||||
```
|
|
||||||
|
|
||||||
Rerun the app to apply the migrations.
|
|
||||||
|
|
||||||
_Note: For safety, development builds use a separate database location from production builds._
|
|
||||||
|
|
||||||
## Lezer Grammar Generation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Example
|
|
||||||
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Linting and Formatting
|
|
||||||
|
|
||||||
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
|
|
||||||
|
|
||||||
- Lint the entire repo:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
- Format code:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run format
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- A pre-commit hook runs `vp lint` automatically on commit.
|
|
||||||
- Some workspace packages also run `tsc --noEmit` for type-checking.
|
|
||||||
- VS Code users should install the recommended extensions for format-on-save support.
|
|
||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Yaak
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.PHONY: sqlx-prepare, dev, migrate, build
|
||||||
|
|
||||||
|
sqlx-prepare:
|
||||||
|
cd src-tauri && cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||||
|
|
||||||
|
dev:
|
||||||
|
npm run tauri-dev
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
cd src-tauri && cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
|
||||||
|
|
||||||
|
build:
|
||||||
|
./node_modules/.bin/tauri build
|
||||||
78
README.md
78
README.md
@@ -1,70 +1,16 @@
|
|||||||
<p align="center">
|
# Yaak Network Toolkit
|
||||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
|
||||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">
|
The most fun you'll ever have working with APIs.
|
||||||
💫 Yaak ➟ Desktop API Client 💫
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
## Common Commands
|
||||||
A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
Development is funded by community-purchased <a href="https://yaak.app/pricing">licenses</a>. You can also <a href="https://github.com/sponsors/gschier">become a sponsor</a> to have your logo appear below. 💖
|
|
||||||
</p>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<p align="center">
|
```sh
|
||||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
# Start dev app
|
||||||
</p>
|
npm run tauri-dev
|
||||||
<p align="center">
|
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
# Migration commands
|
||||||
|
cd src-tauri
|
||||||
## Features
|
cargo sqlx migrate add ${MIGRATION_NAME}
|
||||||
|
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
|
||||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||||
Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
|
```
|
||||||
|
|
||||||
### 🌐 Work with any API
|
|
||||||
|
|
||||||
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
|
|
||||||
- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
|
|
||||||
- Filter and inspect responses with JSONPath or XPath.
|
|
||||||
|
|
||||||
### 🔐 Stay secure
|
|
||||||
|
|
||||||
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
|
||||||
- Secure sensitive values with encrypted secrets.
|
|
||||||
- Store secrets in your OS keychain.
|
|
||||||
|
|
||||||
### ☁️ Organize & collaborate
|
|
||||||
|
|
||||||
- Group requests into workspaces and nested folders.
|
|
||||||
- Use environment variables to switch between dev, staging, and prod.
|
|
||||||
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
|
|
||||||
|
|
||||||
### 🧩 Extend & customize
|
|
||||||
|
|
||||||
- Insert dynamic values like UUIDs or timestamps with template tags.
|
|
||||||
- Pick from built-in themes or build your own.
|
|
||||||
- Create plugins to extend authentication, template tags, or the UI.
|
|
||||||
|
|
||||||
## Contribution Policy
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
|
||||||
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
|
|
||||||
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
|
||||||
|
|
||||||
## Useful Resources
|
|
||||||
|
|
||||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
|
||||||
- [Documentation](https://yaak.app/docs)
|
|
||||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
|
||||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
|
||||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
|
||||||
|
|||||||
2
apps/yaak-client/.gitignore
vendored
2
apps/yaak-client/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
vite.config.d.ts
|
|
||||||
vite.config.js
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models";
|
|
||||||
import { applySync, calculateSync } from "@yaakapp-internal/sync";
|
|
||||||
import { Button } from "../components/core/Button";
|
|
||||||
import {
|
|
||||||
Banner,
|
|
||||||
InlineCode,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
|
||||||
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
|
||||||
|
|
||||||
export const createFolder = createFastMutation<
|
|
||||||
string | null,
|
|
||||||
void,
|
|
||||||
Partial<Pick<Folder, "name" | "sortPriority" | "folderId">>
|
|
||||||
>({
|
|
||||||
mutationKey: ["create_folder"],
|
|
||||||
mutationFn: async (patch) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) {
|
|
||||||
throw new Error("Cannot create folder when there's no active workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!patch.name) {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "new-folder",
|
|
||||||
label: "Name",
|
|
||||||
defaultValue: "Folder",
|
|
||||||
title: "New Folder",
|
|
||||||
confirmText: "Create",
|
|
||||||
placeholder: "Name",
|
|
||||||
});
|
|
||||||
if (name == null) return null;
|
|
||||||
|
|
||||||
patch.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
patch.sortPriority = patch.sortPriority || -Date.now();
|
|
||||||
const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const syncWorkspace = createFastMutation<
|
|
||||||
void,
|
|
||||||
void,
|
|
||||||
{ workspaceId: string; syncDir: string; force?: boolean }
|
|
||||||
>({
|
|
||||||
mutationKey: [],
|
|
||||||
mutationFn: async ({ workspaceId, syncDir, force }) => {
|
|
||||||
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
|
|
||||||
if (ops.length === 0) {
|
|
||||||
console.log("Nothing to sync", workspaceId, syncDir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("Syncing workspace", workspaceId, syncDir, ops);
|
|
||||||
|
|
||||||
const dbOps = ops.filter((o) => o.type.startsWith("db"));
|
|
||||||
|
|
||||||
if (dbOps.length === 0) {
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDeletingWorkspace = ops.some(
|
|
||||||
(o) => o.type === "dbDelete" && o.model.model === "workspace",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Directory changes detected", { dbOps, ops });
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "commit-sync",
|
|
||||||
title: "Changes Detected",
|
|
||||||
size: "md",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<form
|
|
||||||
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
hide();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDeletingWorkspace ? (
|
|
||||||
<Banner color="danger">
|
|
||||||
🚨 <strong>Changes contain a workspace deletion!</strong>
|
|
||||||
</Banner>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<p>
|
|
||||||
{pluralizeCount("file", dbOps.length)} in the directory{" "}
|
|
||||||
{dbOps.length === 1 ? "has" : "have"} changed. Do you want to update your workspace?
|
|
||||||
</p>
|
|
||||||
<Table scrollable className="my-4">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Operation</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{dbOps.map((op, i) => {
|
|
||||||
let name: string;
|
|
||||||
let label: string;
|
|
||||||
let color: string;
|
|
||||||
let model: string;
|
|
||||||
|
|
||||||
if (op.type === "dbCreate") {
|
|
||||||
label = "create";
|
|
||||||
name = resolvedModelNameWithFolders(op.fs.model);
|
|
||||||
color = "text-success";
|
|
||||||
model = modelTypeLabel(op.fs.model);
|
|
||||||
} else if (op.type === "dbUpdate") {
|
|
||||||
label = "update";
|
|
||||||
name = resolvedModelNameWithFolders(op.fs.model);
|
|
||||||
color = "text-info";
|
|
||||||
model = modelTypeLabel(op.fs.model);
|
|
||||||
} else if (op.type === "dbDelete") {
|
|
||||||
label = "delete";
|
|
||||||
name = resolvedModelNameWithFolders(op.model);
|
|
||||||
color = "text-danger";
|
|
||||||
model = modelTypeLabel(op.model);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell className="text-text-subtle">{model}</TableCell>
|
|
||||||
<TruncatedWideTableCell>{name}</TruncatedWideTableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<InlineCode className={color}>{label}</InlineCode>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<footer className="py-3 flex flex-row-reverse items-center gap-3">
|
|
||||||
<Button type="submit" color="primary">
|
|
||||||
Apply Changes
|
|
||||||
</Button>
|
|
||||||
<Button onClick={hide} color="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
|
|
||||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
|
||||||
string | null,
|
|
||||||
unknown,
|
|
||||||
Environment | null
|
|
||||||
>({
|
|
||||||
mutationKey: ["create_environment"],
|
|
||||||
mutationFn: async (baseEnvironment) => {
|
|
||||||
if (baseEnvironment == null) {
|
|
||||||
throw new Error("No base environment passed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) {
|
|
||||||
throw new Error("Cannot create environment when no active workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<string | null>((resolve) => {
|
|
||||||
showDialog({
|
|
||||||
id: "new-environment",
|
|
||||||
title: "New Environment",
|
|
||||||
description: "Create multiple environments with different sets of variables",
|
|
||||||
size: "sm",
|
|
||||||
onClose: () => resolve(null),
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<CreateEnvironmentDialog
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
hide={hide}
|
|
||||||
onCreate={(id: string) => {
|
|
||||||
resolve(id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (environmentId) => {
|
|
||||||
if (environmentId == null) {
|
|
||||||
return; // Was not created
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
|
|
||||||
export const deleteWebsocketConnections = createFastMutation({
|
|
||||||
mutationKey: ["delete_websocket_connections"],
|
|
||||||
mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
|
|
||||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export const moveToWorkspace = createFastMutation({
|
|
||||||
mutationKey: ["move_workspace"],
|
|
||||||
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
|
|
||||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (activeWorkspaceId == null) return;
|
|
||||||
if (requests.length === 0) return;
|
|
||||||
|
|
||||||
const title =
|
|
||||||
requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "change-workspace",
|
|
||||||
title,
|
|
||||||
size: "sm",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<MoveToWorkspaceDialog
|
|
||||||
onDone={hide}
|
|
||||||
requests={requests}
|
|
||||||
activeWorkspaceId={activeWorkspaceId}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { getModel } from "@yaakapp-internal/models";
|
|
||||||
import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
|
|
||||||
import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
|
|
||||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
|
||||||
const folder = getModel("folder", folderId);
|
|
||||||
if (folder == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "folder-settings",
|
|
||||||
title: null,
|
|
||||||
size: "lg",
|
|
||||||
className: "h-[50rem]",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { SettingsTab } from "../components/Settings/Settings";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
|
|
||||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
|
||||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
|
||||||
mutationKey: ["open_settings"],
|
|
||||||
mutationFn: async (tab) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
|
|
||||||
const location = router.buildLocation({
|
|
||||||
to: "/workspaces/$workspaceId/settings",
|
|
||||||
params: { workspaceId },
|
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
await invokeCmd("cmd_new_child_window", {
|
|
||||||
url: location.href,
|
|
||||||
label: "settings",
|
|
||||||
title: "Yaak Settings",
|
|
||||||
innerSize: [750, 600],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showSimpleAlert } from "../lib/alert";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
|
|
||||||
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
|
|
||||||
mutationKey: [],
|
|
||||||
mutationFn: async (dir) => {
|
|
||||||
const ops = await calculateSyncFsOnly(dir);
|
|
||||||
|
|
||||||
const workspace = ops
|
|
||||||
.map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
|
|
||||||
.filter((m) => m)[0];
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
showSimpleAlert("Failed to Open", "No workspace found in directory");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await applySync(workspace.id, dir, ops);
|
|
||||||
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: workspace.id },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "workspace-settings",
|
|
||||||
size: "md",
|
|
||||||
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
|
||||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
|
|
||||||
export const switchWorkspace = createFastMutation<
|
|
||||||
void,
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
workspaceId: string;
|
|
||||||
inNewWindow: boolean;
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
mutationKey: ["open_workspace"],
|
|
||||||
mutationFn: async ({ workspaceId, inNewWindow }) => {
|
|
||||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
|
|
||||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
|
|
||||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
|
|
||||||
const search = {
|
|
||||||
environment_id: environmentId,
|
|
||||||
cookie_jar_id: cookieJarId,
|
|
||||||
request_id: requestId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inNewWindow) {
|
|
||||||
const location = router.buildLocation({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId },
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId },
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import mime from "mime";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
requestId: string;
|
|
||||||
contentType: string | null;
|
|
||||||
body: HttpRequest["body"];
|
|
||||||
onChange: (body: HttpRequest["body"]) => void;
|
|
||||||
onChangeContentType: (contentType: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BinaryFileEditor({
|
|
||||||
contentType,
|
|
||||||
body,
|
|
||||||
onChange,
|
|
||||||
onChangeContentType,
|
|
||||||
requestId,
|
|
||||||
}: Props) {
|
|
||||||
const ignoreContentType = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
|
||||||
key: ["ignore_content_type", requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChange = async ({ filePath }: { filePath: string | null }) => {
|
|
||||||
await ignoreContentType.set(false);
|
|
||||||
onChange({ filePath: filePath ?? undefined });
|
|
||||||
};
|
|
||||||
|
|
||||||
const filePath = typeof body.filePath === "string" ? body.filePath : null;
|
|
||||||
const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={2}>
|
|
||||||
<SelectFile onChange={handleChange} filePath={filePath} />
|
|
||||||
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
|
|
||||||
<Banner className="mt-3 !py-5">
|
|
||||||
<div className="mb-4 text-center">
|
|
||||||
<div>Set Content-Type header</div>
|
|
||||||
<InlineCode>{mimeType}</InlineCode> for current request?
|
|
||||||
</div>
|
|
||||||
<HStack space={1.5} justifyContent="center">
|
|
||||||
<Button size="sm" variant="border" onClick={() => ignoreContentType.set(true)}>
|
|
||||||
Ignore
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onChangeContentType(mimeType)}
|
|
||||||
>
|
|
||||||
Set Header
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
feature: "updater" | "license";
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureMap: Record<Props["feature"], boolean> = {
|
|
||||||
updater: appInfo.featureUpdater,
|
|
||||||
license: appInfo.featureLicense,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CargoFeature({ children, feature }: Props) {
|
|
||||||
if (featureMap[feature]) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { gitClone } from "@yaakapp-internal/git";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { showErrorToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { promptCredentials } from "./git/credentials";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
hide: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect path separator from an existing path (defaults to /)
|
|
||||||
function getPathSeparator(path: string): string {
|
|
||||||
return path.includes("\\") ? "\\" : "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CloneGitRepositoryDialog({ hide }: Props) {
|
|
||||||
const [url, setUrl] = useState<string>("");
|
|
||||||
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
|
|
||||||
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
|
|
||||||
const [hasSubdirectory, setHasSubdirectory] = useState(false);
|
|
||||||
const [subdirectory, setSubdirectory] = useState<string>("");
|
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const repoName = extractRepoName(url);
|
|
||||||
const sep = getPathSeparator(baseDirectory);
|
|
||||||
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
|
|
||||||
const directory = directoryOverride ?? computedDirectory;
|
|
||||||
const workspaceDirectory =
|
|
||||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
|
||||||
const dir = await open({
|
|
||||||
title: "Select Directory",
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
if (dir != null) {
|
|
||||||
setBaseDirectory(dir);
|
|
||||||
setDirectoryOverride(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClone = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!url || !directory) return;
|
|
||||||
|
|
||||||
setIsCloning(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await gitClone(url, directory, promptCredentials);
|
|
||||||
|
|
||||||
if (result.type === "needs_credentials") {
|
|
||||||
setError(
|
|
||||||
result.error ?? "Authentication failed. Please check your credentials and try again.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the workspace from the cloned directory (or subdirectory)
|
|
||||||
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
|
|
||||||
|
|
||||||
hide();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-clone-error",
|
|
||||||
title: "Clone Failed",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCloning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
|
||||||
{error && (
|
|
||||||
<Banner color="danger" className="w-full">
|
|
||||||
{error}
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlainInput
|
|
||||||
required
|
|
||||||
label="Repository URL"
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
|
||||||
defaultValue={url}
|
|
||||||
onChange={setUrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PlainInput
|
|
||||||
label="Directory"
|
|
||||||
placeholder={appInfo.defaultProjectDir}
|
|
||||||
defaultValue={directory}
|
|
||||||
onChange={setDirectoryOverride}
|
|
||||||
rightSlot={
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
className="mr-0.5 !h-auto my-0.5"
|
|
||||||
icon="folder"
|
|
||||||
title="Browse"
|
|
||||||
onClick={handleSelectDirectory}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
checked={hasSubdirectory}
|
|
||||||
onChange={setHasSubdirectory}
|
|
||||||
title="Workspace is in a subdirectory"
|
|
||||||
help="Enable if the Yaak workspace files are not at the root of the repository"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasSubdirectory && (
|
|
||||||
<PlainInput
|
|
||||||
label="Subdirectory"
|
|
||||||
placeholder="path/to/workspace"
|
|
||||||
defaultValue={subdirectory}
|
|
||||||
onChange={setSubdirectory}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
color="primary"
|
|
||||||
className="w-full mt-3"
|
|
||||||
disabled={!url || !directory || isCloning}
|
|
||||||
isLoading={isCloning}
|
|
||||||
>
|
|
||||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRepoName(url: string): string {
|
|
||||||
// Handle various Git URL formats:
|
|
||||||
// https://github.com/user/repo.git
|
|
||||||
// git@github.com:user/repo.git
|
|
||||||
// https://github.com/user/repo
|
|
||||||
const match = url.match(/\/([^/]+?)(\.git)?$/);
|
|
||||||
if (match?.[1]) {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
// Fallback for SSH-style URLs
|
|
||||||
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
|
|
||||||
if (sshMatch?.[1]) {
|
|
||||||
return sshMatch[1];
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
color: string | null;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorIndicator({ color, onClick, className }: Props) {
|
|
||||||
const style: CSSProperties = { backgroundColor: color ?? undefined };
|
|
||||||
const finalClassName = classNames(
|
|
||||||
className,
|
|
||||||
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onClick) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
style={style}
|
|
||||||
className={classNames(finalClassName, "hover:border-text")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span style={style} className={finalClassName} />;
|
|
||||||
}
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { fuzzyFilter } from "fuzzbunny";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
Fragment,
|
|
||||||
type KeyboardEvent,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { createFolder } from "../commands/commands";
|
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
|
||||||
import { openSettings } from "../commands/openSettings";
|
|
||||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
|
||||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
|
||||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { useAllRequests } from "../hooks/useAllRequests";
|
|
||||||
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
|
||||||
import type { HotkeyAction } from "../hooks/useHotKey";
|
|
||||||
import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
|
|
||||||
import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
|
|
||||||
import { useRecentRequests } from "../hooks/useRecentRequests";
|
|
||||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
|
||||||
import { useScrollIntoView } from "../hooks/useScrollIntoView";
|
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { copyToClipboard } from "../lib/copy";
|
|
||||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { editEnvironment } from "../lib/editEnvironment";
|
|
||||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
|
||||||
import {
|
|
||||||
resolvedModelNameWithFolders,
|
|
||||||
resolvedModelNameWithFoldersArray,
|
|
||||||
} from "../lib/resolvedModelName";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
import { CookieDialog } from "./CookieDialog";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Hotkey } from "./core/Hotkey";
|
|
||||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface CommandPaletteGroup {
|
|
||||||
key: string;
|
|
||||||
label: ReactNode;
|
|
||||||
items: CommandPaletteItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandPaletteItem = {
|
|
||||||
key: string;
|
|
||||||
onSelect: () => void;
|
|
||||||
action?: HotkeyAction;
|
|
||||||
} & ({ searchText: string; label: ReactNode } | { label: string });
|
|
||||||
|
|
||||||
const MAX_PER_GROUP = 8;
|
|
||||||
|
|
||||||
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|
||||||
const [command, setCommand] = useDebouncedState<string>("", 150);
|
|
||||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
|
||||||
const activeEnvironment = useActiveEnvironment();
|
|
||||||
const httpRequestActions = useHttpRequestActions();
|
|
||||||
const grpcRequestActions = useGrpcRequestActions();
|
|
||||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
const createWorkspace = useCreateWorkspace();
|
|
||||||
const recentEnvironments = useRecentEnvironments();
|
|
||||||
const recentWorkspaces = useRecentWorkspaces();
|
|
||||||
const requests = useAllRequests();
|
|
||||||
const activeRequest = useActiveRequest();
|
|
||||||
const activeCookieJar = useActiveCookieJar();
|
|
||||||
const [recentRequests] = useRecentRequests();
|
|
||||||
const [, setSidebarHidden] = useSidebarHidden();
|
|
||||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
|
||||||
|
|
||||||
const handleSetCommand = (command: string) => {
|
|
||||||
setCommand(command);
|
|
||||||
setSelectedItemKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
|
|
||||||
if (workspaceId == null) return [];
|
|
||||||
|
|
||||||
const commands: CommandPaletteItem[] = [
|
|
||||||
{
|
|
||||||
key: "settings.open",
|
|
||||||
label: "Open Settings",
|
|
||||||
action: "settings.show",
|
|
||||||
onSelect: () => openSettings.mutate(null),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "app.create",
|
|
||||||
label: "Create Workspace",
|
|
||||||
onSelect: createWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "model.create",
|
|
||||||
label: "Create HTTP Request",
|
|
||||||
onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "grpc_request.create",
|
|
||||||
label: "Create GRPC Request",
|
|
||||||
onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "websocket_request.create",
|
|
||||||
label: "Create Websocket Request",
|
|
||||||
onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "folder.create",
|
|
||||||
label: "Create Folder",
|
|
||||||
onSelect: () => createFolder.mutate({}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cookies.show",
|
|
||||||
label: "Show Cookies",
|
|
||||||
onSelect: async () => {
|
|
||||||
showDialog({
|
|
||||||
id: "cookies",
|
|
||||||
title: "Manage Cookies",
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "environment.edit",
|
|
||||||
label: "Edit Environment",
|
|
||||||
action: "environment_editor.toggle",
|
|
||||||
onSelect: () => editEnvironment(activeEnvironment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "environment.create",
|
|
||||||
label: "Create Environment",
|
|
||||||
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sidebar.toggle",
|
|
||||||
label: "Toggle Sidebar",
|
|
||||||
action: "sidebar.focus",
|
|
||||||
onSelect: () => setSidebarHidden((h) => !h),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (activeRequest?.model === "http_request") {
|
|
||||||
commands.push({
|
|
||||||
key: "request.send",
|
|
||||||
action: "request.send",
|
|
||||||
label: "Send Request",
|
|
||||||
onSelect: () => sendRequest(activeRequest.id),
|
|
||||||
});
|
|
||||||
if (appInfo.cliVersion != null) {
|
|
||||||
commands.push({
|
|
||||||
key: "request.copy_cli_send",
|
|
||||||
searchText: `copy cli send yaak request send ${activeRequest.id}`,
|
|
||||||
label: "Copy CLI Send Command",
|
|
||||||
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
httpRequestActions.forEach((a, i) => {
|
|
||||||
commands.push({
|
|
||||||
key: `http_request_action.${i}`,
|
|
||||||
label: a.label,
|
|
||||||
onSelect: () => a.call(activeRequest),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeRequest?.model === "grpc_request") {
|
|
||||||
grpcRequestActions.forEach((a, i) => {
|
|
||||||
commands.push({
|
|
||||||
key: `grpc_request_action.${i}`,
|
|
||||||
label: a.label,
|
|
||||||
onSelect: () => a.call(activeRequest),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeRequest != null) {
|
|
||||||
commands.push({
|
|
||||||
key: "http_request.rename",
|
|
||||||
label: "Rename Request",
|
|
||||||
onSelect: () => renameModelWithPrompt(activeRequest),
|
|
||||||
});
|
|
||||||
|
|
||||||
commands.push({
|
|
||||||
key: "sidebar.selected.delete",
|
|
||||||
label: "Delete Request",
|
|
||||||
onSelect: () => deleteModelWithConfirm(activeRequest),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands.sort((a, b) =>
|
|
||||||
("searchText" in a ? a.searchText : a.label).localeCompare(
|
|
||||||
"searchText" in b ? b.searchText : b.label,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
activeCookieJar?.id,
|
|
||||||
activeEnvironment,
|
|
||||||
activeRequest,
|
|
||||||
baseEnvironment,
|
|
||||||
createWorkspace,
|
|
||||||
grpcRequestActions,
|
|
||||||
httpRequestActions,
|
|
||||||
sendRequest,
|
|
||||||
setSidebarHidden,
|
|
||||||
workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sortedRequests = useMemo(() => {
|
|
||||||
return [...requests].sort((a, b) => {
|
|
||||||
const aRecentIndex = recentRequests.indexOf(a.id);
|
|
||||||
const bRecentIndex = recentRequests.indexOf(b.id);
|
|
||||||
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
|
||||||
return aRecentIndex - bRecentIndex;
|
|
||||||
}
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return a.createdAt.localeCompare(b.createdAt);
|
|
||||||
});
|
|
||||||
}, [recentRequests, requests]);
|
|
||||||
|
|
||||||
const sortedEnvironments = useMemo(() => {
|
|
||||||
return [...subEnvironments].sort((a, b) => {
|
|
||||||
const aRecentIndex = recentEnvironments.indexOf(a.id);
|
|
||||||
const bRecentIndex = recentEnvironments.indexOf(b.id);
|
|
||||||
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
|
||||||
return aRecentIndex - bRecentIndex;
|
|
||||||
}
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return a.createdAt.localeCompare(b.createdAt);
|
|
||||||
});
|
|
||||||
}, [subEnvironments, recentEnvironments]);
|
|
||||||
|
|
||||||
const sortedWorkspaces = useMemo(() => {
|
|
||||||
if (recentWorkspaces == null) {
|
|
||||||
// Should never happen
|
|
||||||
return workspaces;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...workspaces].sort((a, b) => {
|
|
||||||
const aRecentIndex = recentWorkspaces?.indexOf(a.id);
|
|
||||||
const bRecentIndex = recentWorkspaces?.indexOf(b.id);
|
|
||||||
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
|
||||||
return aRecentIndex - bRecentIndex;
|
|
||||||
}
|
|
||||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return a.createdAt.localeCompare(b.createdAt);
|
|
||||||
});
|
|
||||||
}, [recentWorkspaces, workspaces]);
|
|
||||||
|
|
||||||
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
|
||||||
const actionsGroup: CommandPaletteGroup = {
|
|
||||||
key: "actions",
|
|
||||||
label: "Actions",
|
|
||||||
items: workspaceCommands,
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestGroup: CommandPaletteGroup = {
|
|
||||||
key: "requests",
|
|
||||||
label: "Switch Request",
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const r of sortedRequests) {
|
|
||||||
requestGroup.items.push({
|
|
||||||
key: `switch-request-${r.id}`,
|
|
||||||
searchText: resolvedModelNameWithFolders(r),
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-x-0.5">
|
|
||||||
<HttpMethodTag short className="text-xs mr-2" request={r} />
|
|
||||||
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
|
|
||||||
<Fragment key={name}>
|
|
||||||
{i !== 0 && <Icon icon="chevron_right" className="opacity-80" />}
|
|
||||||
<div className={classNames(i < all.length - 1 && "truncate")}>{name}</div>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
onSelect: async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: r.workspaceId },
|
|
||||||
search: (prev) => ({ ...prev, request_id: r.id }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const environmentGroup: CommandPaletteGroup = {
|
|
||||||
key: "environments",
|
|
||||||
label: "Switch Environment",
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const e of sortedEnvironments) {
|
|
||||||
if (e.id === activeEnvironment?.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
environmentGroup.items.push({
|
|
||||||
key: `switch-environment-${e.id}`,
|
|
||||||
label: e.name,
|
|
||||||
onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceGroup: CommandPaletteGroup = {
|
|
||||||
key: "workspaces",
|
|
||||||
label: "Switch Workspace",
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const w of sortedWorkspaces) {
|
|
||||||
workspaceGroup.items.push({
|
|
||||||
key: `switch-workspace-${w.id}`,
|
|
||||||
label: w.name,
|
|
||||||
onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
|
|
||||||
}, [
|
|
||||||
workspaceCommands,
|
|
||||||
sortedRequests,
|
|
||||||
sortedEnvironments,
|
|
||||||
activeEnvironment?.id,
|
|
||||||
sortedWorkspaces,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
|
||||||
|
|
||||||
const { filteredGroups, filteredAllItems } = useMemo(() => {
|
|
||||||
const result = command
|
|
||||||
? fuzzyFilter(
|
|
||||||
allItems.map((i) => ({
|
|
||||||
...i,
|
|
||||||
filterBy: "searchText" in i ? i.searchText : i.label,
|
|
||||||
})),
|
|
||||||
command,
|
|
||||||
{ fields: ["filterBy"] },
|
|
||||||
)
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.map((v) => v.item)
|
|
||||||
: allItems;
|
|
||||||
|
|
||||||
const filteredGroups = groups
|
|
||||||
.map((g) => {
|
|
||||||
const items = result
|
|
||||||
.filter((i) => g.items.find((i2) => i2.key === i.key))
|
|
||||||
.slice(0, MAX_PER_GROUP);
|
|
||||||
return { ...g, items };
|
|
||||||
})
|
|
||||||
.filter((g) => g.items.length > 0);
|
|
||||||
|
|
||||||
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
|
|
||||||
return { filteredAllItems, filteredGroups };
|
|
||||||
}, [allItems, command, groups]);
|
|
||||||
|
|
||||||
const handleSelectAndClose = useCallback(
|
|
||||||
(cb: () => void) => {
|
|
||||||
onClose();
|
|
||||||
cb();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedItem = useMemo(() => {
|
|
||||||
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
|
|
||||||
if (selectedItem == null) {
|
|
||||||
selectedItem = filteredAllItems[0] ?? null;
|
|
||||||
}
|
|
||||||
return selectedItem;
|
|
||||||
}, [filteredAllItems, selectedItemKey]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
|
||||||
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
|
|
||||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
|
||||||
setSelectedItemKey(next?.key ?? null);
|
|
||||||
} else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
|
|
||||||
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
|
|
||||||
setSelectedItemKey(prev?.key ?? null);
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
const selected = filteredAllItems[index];
|
|
||||||
setSelectedItemKey(selected?.key ?? null);
|
|
||||||
if (selected) {
|
|
||||||
handleSelectAndClose(selected.onSelect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
|
|
||||||
<div className="px-2 w-full">
|
|
||||||
<PlainInput
|
|
||||||
autoFocus
|
|
||||||
hideLabel
|
|
||||||
leftSlot={
|
|
||||||
<div className="h-md w-10 flex justify-center items-center">
|
|
||||||
<Icon icon="search" color="secondary" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
name="command"
|
|
||||||
label="Command"
|
|
||||||
placeholder="Search or type a command"
|
|
||||||
className="font-sans !text-base"
|
|
||||||
defaultValue={command}
|
|
||||||
onChange={handleSetCommand}
|
|
||||||
onKeyDownCapture={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
|
|
||||||
{filteredGroups.map((g) => (
|
|
||||||
<div key={g.key} className="mb-1.5 w-full">
|
|
||||||
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
|
||||||
{g.label}
|
|
||||||
</Heading>
|
|
||||||
{g.items.map((v) => (
|
|
||||||
<CommandPaletteItem
|
|
||||||
active={v.key === selectedItem?.key}
|
|
||||||
key={v.key}
|
|
||||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
|
||||||
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
|
|
||||||
>
|
|
||||||
{v.label}
|
|
||||||
</CommandPaletteItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPaletteItem({
|
|
||||||
children,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
rightSlot,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
|
||||||
useScrollIntoView(ref.current, active);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
onClick={onClick}
|
|
||||||
tabIndex={active ? undefined : -1}
|
|
||||||
rightSlot={rightSlot}
|
|
||||||
color="custom"
|
|
||||||
justify="start"
|
|
||||||
className={classNames(
|
|
||||||
"w-full h-sm flex items-center rounded px-1.5",
|
|
||||||
"hover:text-text",
|
|
||||||
active && "bg-surface-highlight",
|
|
||||||
!active && "text-text-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{children}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
|
|
||||||
return <Hotkey className="ml-auto" action={action} />;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
request: HttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
|
|
||||||
|
|
||||||
export function ConfirmLargeRequestBody({ children, request }: Props) {
|
|
||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
|
||||||
|
|
||||||
if (request.body?.text == null) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = request.body.text.length ?? 0;
|
|
||||||
const tooLargeBytes = LARGE_TEXT_BYTES;
|
|
||||||
const isLarge = contentLength > tooLargeBytes;
|
|
||||||
if (!showLargeResponse && isLarge) {
|
|
||||||
return (
|
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Rendering content over{" "}
|
|
||||||
<InlineCode>
|
|
||||||
<SizeTag contentLength={tooLargeBytes} />
|
|
||||||
</InlineCode>{" "}
|
|
||||||
may impact performance.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
See{" "}
|
|
||||||
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
|
|
||||||
Working With Large Values
|
|
||||||
</Link>{" "}
|
|
||||||
for tips.
|
|
||||||
</p>
|
|
||||||
<HStack wrap space={2}>
|
|
||||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
|
||||||
Reveal Body
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="danger"
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={async () => {
|
|
||||||
const confirm = await showConfirm({
|
|
||||||
id: `delete-body-${request.id}`,
|
|
||||||
confirmText: "Delete Body",
|
|
||||||
title: "Delete Body Text",
|
|
||||||
description: "Are you sure you want to delete the request body text?",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (confirm) {
|
|
||||||
await patchModel(request, { body: { ...request.body, text: "" } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Body
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { type ReactNode, useMemo } from "react";
|
|
||||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { isProbablyTextContentType } from "../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
|
||||||
import { getResponseBodyText } from "../lib/responseBody";
|
|
||||||
import { CopyButton } from "./CopyButton";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
|
||||||
|
|
||||||
export function ConfirmLargeResponse({ children, response }: Props) {
|
|
||||||
const { mutate: saveResponse } = useSaveResponse(response);
|
|
||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
|
||||||
const isProbablyText = useMemo(() => {
|
|
||||||
const contentType = getContentTypeFromHeaders(response.headers);
|
|
||||||
return isProbablyTextContentType(contentType);
|
|
||||||
}, [response.headers]);
|
|
||||||
|
|
||||||
const contentLength = response.contentLength ?? 0;
|
|
||||||
const isLarge = contentLength > LARGE_BYTES;
|
|
||||||
if (!showLargeResponse && isLarge) {
|
|
||||||
return (
|
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Showing responses over{" "}
|
|
||||||
<InlineCode>
|
|
||||||
<SizeTag contentLength={LARGE_BYTES} />
|
|
||||||
</InlineCode>{" "}
|
|
||||||
may impact performance
|
|
||||||
</p>
|
|
||||||
<HStack wrap space={2}>
|
|
||||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
|
||||||
Reveal Response
|
|
||||||
</Button>
|
|
||||||
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
|
|
||||||
Save to File
|
|
||||||
</Button>
|
|
||||||
{isProbablyText && (
|
|
||||||
<CopyButton
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
text={() => getResponseBodyText({ response, filter: null })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { type ReactNode, useMemo } from "react";
|
|
||||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { isProbablyTextContentType } from "../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
|
||||||
import { CopyButton } from "./CopyButton";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
|
||||||
|
|
||||||
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
|
||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
|
||||||
const isProbablyText = useMemo(() => {
|
|
||||||
const contentType = getContentTypeFromHeaders(response.headers);
|
|
||||||
return isProbablyTextContentType(contentType);
|
|
||||||
}, [response.headers]);
|
|
||||||
|
|
||||||
const contentLength = response.requestContentLength ?? 0;
|
|
||||||
const isLarge = contentLength > LARGE_BYTES;
|
|
||||||
if (!showLargeResponse && isLarge) {
|
|
||||||
return (
|
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Showing content over{" "}
|
|
||||||
<InlineCode>
|
|
||||||
<SizeTag contentLength={LARGE_BYTES} />
|
|
||||||
</InlineCode>{" "}
|
|
||||||
may impact performance
|
|
||||||
</p>
|
|
||||||
<HStack wrap space={2}>
|
|
||||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
|
||||||
Reveal Request Body
|
|
||||||
</Button>
|
|
||||||
{isProbablyText && (
|
|
||||||
<CopyButton
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { Cookie } from "@yaakapp-internal/models";
|
|
||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { cookieDomain } from "../lib/model_util";
|
|
||||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cookieJarId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
|
||||||
|
|
||||||
if (cookieJar == null) {
|
|
||||||
return <div>No cookie jar selected</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookieJar.cookies.length === 0) {
|
|
||||||
return (
|
|
||||||
<Banner>
|
|
||||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-2">
|
|
||||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 text-left">Domain</th>
|
|
||||||
<th className="py-2 text-left pl-4">Cookie</th>
|
|
||||||
<th className="py-2 pl-4" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{cookieJar?.cookies.map((c: Cookie) => (
|
|
||||||
<tr key={JSON.stringify(c)}>
|
|
||||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
|
||||||
{cookieDomain(c)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
|
||||||
{c.raw_cookie}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-0 w-10">
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Delete"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={() =>
|
|
||||||
patchModel(cookieJar, {
|
|
||||||
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
|
||||||
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
import { CookieDialog } from "./CookieDialog";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
export const CookieDropdown = memo(function CookieDropdown() {
|
|
||||||
const activeCookieJar = useActiveCookieJar();
|
|
||||||
const createCookieJar = useCreateCookieJar();
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
|
|
||||||
const items = useMemo((): DropdownItem[] => {
|
|
||||||
return [
|
|
||||||
...(cookieJars ?? []).map((j) => ({
|
|
||||||
key: j.id,
|
|
||||||
label: j.name,
|
|
||||||
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? "check" : "empty"} />,
|
|
||||||
onSelect: () => {
|
|
||||||
setWorkspaceSearchParams({ cookie_jar_id: j.id });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...(((cookieJars ?? []).length > 0 && activeCookieJar != null
|
|
||||||
? [
|
|
||||||
{ type: "separator", label: activeCookieJar.name },
|
|
||||||
{
|
|
||||||
key: "manage",
|
|
||||||
label: "Manage Cookies",
|
|
||||||
leftSlot: <Icon icon="cookie" />,
|
|
||||||
onSelect: () => {
|
|
||||||
if (activeCookieJar == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "cookies",
|
|
||||||
title: "Manage Cookies",
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rename",
|
|
||||||
label: "Rename",
|
|
||||||
leftSlot: <Icon icon="pencil" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "rename-cookie-jar",
|
|
||||||
title: "Rename Cookie Jar",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
label: "Name",
|
|
||||||
confirmText: "Save",
|
|
||||||
placeholder: "New name",
|
|
||||||
defaultValue: activeCookieJar?.name,
|
|
||||||
});
|
|
||||||
if (name == null) return;
|
|
||||||
await patchModel(activeCookieJar, { name });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(((cookieJars ?? []).length > 1 // Never delete the last one
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
color: "danger",
|
|
||||||
onSelect: async () => {
|
|
||||||
await deleteModelWithConfirm(activeCookieJar);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []) as DropdownItem[]),
|
|
||||||
]
|
|
||||||
: []) as DropdownItem[]),
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
key: "create-cookie-jar",
|
|
||||||
label: "New Cookie Jar",
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
onSelect: () => createCookieJar.mutate(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [activeCookieJar, cookieJars, createCookieJar]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<IconButton size="sm" icon="cookie" iconColor="secondary" title="Cookie Jar" />
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useTimedBoolean } from "@yaakapp-internal/ui";
|
|
||||||
import { copyToClipboard } from "../lib/copy";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
|
|
||||||
interface Props extends Omit<ButtonProps, "onClick"> {
|
|
||||||
text: string | (() => Promise<string | null>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyButton({ text, ...props }: Props) {
|
|
||||||
const [copied, setCopied] = useTimedBoolean();
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
onClick={async () => {
|
|
||||||
const content = typeof text === "function" ? await text() : text;
|
|
||||||
if (content == null) {
|
|
||||||
showToast({
|
|
||||||
id: "failed-to-copy",
|
|
||||||
color: "danger",
|
|
||||||
message: "Failed to copy",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(content, { disableToast: true });
|
|
||||||
setCopied();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
|
|
||||||
import { copyToClipboard } from "../lib/copy";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
|
|
||||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
|
||||||
text: string | (() => Promise<string | null>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyIconButton({ text, ...props }: Props) {
|
|
||||||
const [copied, setCopied] = useTimedBoolean();
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
{...props}
|
|
||||||
icon={copied ? "check" : "copy"}
|
|
||||||
showConfirm
|
|
||||||
onClick={async () => {
|
|
||||||
const content = typeof text === "function" ? await text() : text;
|
|
||||||
if (content == null) {
|
|
||||||
showToast({
|
|
||||||
id: "failed-to-copy",
|
|
||||||
color: "danger",
|
|
||||||
message: "Failed to copy",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(content, { disableToast: true });
|
|
||||||
setCopied();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
|
||||||
import type { DropdownProps } from "./core/Dropdown";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
|
|
||||||
interface Props extends Omit<DropdownProps, "items"> {
|
|
||||||
hideFolder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateDropdown({ hideFolder, children, ...props }: Props) {
|
|
||||||
const getItems = useCreateDropdownItems({
|
|
||||||
hideFolder,
|
|
||||||
hideIcons: true,
|
|
||||||
folderId: "active-folder",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={getItems} {...props}>
|
|
||||||
{children}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onCreate: (id: string) => void;
|
|
||||||
hide: () => void;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
|
|
||||||
const [name, setName] = useState<string>("");
|
|
||||||
const [color, setColor] = useState<string | null>(null);
|
|
||||||
const [sharable, toggleSharable] = useToggle(false);
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="pb-3 flex flex-col gap-3"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = await createWorkspaceModel({
|
|
||||||
model: "environment",
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
variables: [],
|
|
||||||
public: sharable,
|
|
||||||
workspaceId,
|
|
||||||
parentModel: "environment",
|
|
||||||
});
|
|
||||||
hide();
|
|
||||||
onCreate(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
defaultValue={name}
|
|
||||||
onChange={setName}
|
|
||||||
placeholder="Production"
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
checked={sharable}
|
|
||||||
title="Share this environment"
|
|
||||||
help="Sharable environments are included in data export and directory sync."
|
|
||||||
onChange={toggleSharable}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
htmlFor="color"
|
|
||||||
className="mb-1.5"
|
|
||||||
help="Select a color to be displayed when this environment is active, to help identify it."
|
|
||||||
>
|
|
||||||
Color
|
|
||||||
</Label>
|
|
||||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
|
||||||
</div>
|
|
||||||
<Button type="submit" color="secondary" className="mt-3">
|
|
||||||
{color != null && <ColorIndicator color={color} />}
|
|
||||||
Create Environment
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { gitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
|
||||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
import { showErrorToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { EncryptionHelp } from "./EncryptionHelp";
|
|
||||||
import { gitCallbacks } from "./git/callbacks";
|
|
||||||
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
hide: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateWorkspaceDialog({ hide }: Props) {
|
|
||||||
const [name, setName] = useState<string>("");
|
|
||||||
const [syncConfig, setSyncConfig] = useState<{
|
|
||||||
filePath: string | null;
|
|
||||||
initGit?: boolean;
|
|
||||||
}>({ filePath: null, initGit: false });
|
|
||||||
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
|
||||||
return (
|
|
||||||
<VStack
|
|
||||||
as="form"
|
|
||||||
space={3}
|
|
||||||
alignItems="start"
|
|
||||||
className="pb-3"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
|
|
||||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
|
||||||
// been created already when the store refreshes the workspace meta after
|
|
||||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
await updateModel({
|
|
||||||
...workspaceMeta,
|
|
||||||
settingSyncDir: syncConfig.filePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (syncConfig.initGit && syncConfig.filePath) {
|
|
||||||
gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))
|
|
||||||
.init.mutateAsync()
|
|
||||||
.catch((err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-init-error",
|
|
||||||
title: "Error initializing Git",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to workspace
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId },
|
|
||||||
});
|
|
||||||
|
|
||||||
hide();
|
|
||||||
|
|
||||||
if (setupEncryption) {
|
|
||||||
setupOrConfigureEncryption();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlainInput required label="Name" defaultValue={name} onChange={setName} />
|
|
||||||
|
|
||||||
<SyncToFilesystemSetting
|
|
||||||
onChange={setSyncConfig}
|
|
||||||
onCreateNewWorkspace={hide}
|
|
||||||
value={syncConfig}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={null} help={<EncryptionHelp />}>
|
|
||||||
Workspace encryption
|
|
||||||
</Label>
|
|
||||||
<Checkbox
|
|
||||||
checked={setupEncryption}
|
|
||||||
onChange={setSetupEncryption}
|
|
||||||
title="Enable Encryption"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" color="primary" className="w-full mt-3">
|
|
||||||
Create Workspace
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { ComponentType } from "react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { dialogsAtom, hideDialog } from "../lib/dialog";
|
|
||||||
import { Dialog, type DialogProps } from "./core/Dialog";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
|
|
||||||
export type DialogInstance = {
|
|
||||||
id: string;
|
|
||||||
render: ComponentType<{ hide: () => void }>;
|
|
||||||
} & Omit<DialogProps, "open" | "children">;
|
|
||||||
|
|
||||||
export function Dialogs() {
|
|
||||||
const dialogs = useAtomValue(dialogsAtom);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{dialogs.map(({ id, ...props }) => (
|
|
||||||
<DialogInstance key={id} id={id} {...props} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
|
|
||||||
const hide = useCallback(() => {
|
|
||||||
hideDialog(id);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose?.();
|
|
||||||
hideDialog(id);
|
|
||||||
}, [id, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onClose={handleClose} {...props}>
|
|
||||||
<ErrorBoundary name={`Dialog ${id}`}>
|
|
||||||
<Component hide={hide} {...props} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import {
|
|
||||||
HStack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
VStack,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback, useId, useMemo } from "react";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
workspace: Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DnsOverrideWithId extends DnsOverride {
|
|
||||||
_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DnsOverridesEditor({ workspace }: Props) {
|
|
||||||
const reactId = useId();
|
|
||||||
|
|
||||||
// Ensure each override has an internal ID for React keys
|
|
||||||
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
|
|
||||||
return workspace.settingDnsOverrides.map((override, index) => ({
|
|
||||||
...override,
|
|
||||||
_id: `${reactId}-${index}`,
|
|
||||||
}));
|
|
||||||
}, [workspace.settingDnsOverrides, reactId]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(overrides: DnsOverride[]) => {
|
|
||||||
fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
|
|
||||||
},
|
|
||||||
[workspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAdd = useCallback(() => {
|
|
||||||
const newOverride: DnsOverride = {
|
|
||||||
hostname: "",
|
|
||||||
ipv4: [""],
|
|
||||||
ipv6: [],
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
handleChange([...workspace.settingDnsOverrides, newOverride]);
|
|
||||||
}, [workspace.settingDnsOverrides, handleChange]);
|
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
|
||||||
(index: number, update: Partial<DnsOverride>) => {
|
|
||||||
const updated = workspace.settingDnsOverrides.map((o, i) =>
|
|
||||||
i === index ? { ...o, ...update } : o,
|
|
||||||
);
|
|
||||||
handleChange(updated);
|
|
||||||
},
|
|
||||||
[workspace.settingDnsOverrides, handleChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
|
|
||||||
handleChange(updated);
|
|
||||||
},
|
|
||||||
[workspace.settingDnsOverrides, handleChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={3} className="pb-3">
|
|
||||||
<div className="text-text-subtle text-sm">
|
|
||||||
Override DNS resolution for specific hostnames. This works like{" "}
|
|
||||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
|
|
||||||
only for requests made from this workspace.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{overridesWithIds.length > 0 && (
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell className="w-8" />
|
|
||||||
<TableHeaderCell>Hostname</TableHeaderCell>
|
|
||||||
<TableHeaderCell>IPv4 Address</TableHeaderCell>
|
|
||||||
<TableHeaderCell>IPv6 Address</TableHeaderCell>
|
|
||||||
<TableHeaderCell className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{overridesWithIds.map((override, index) => (
|
|
||||||
<DnsOverrideRow
|
|
||||||
key={override._id}
|
|
||||||
override={override}
|
|
||||||
onUpdate={(update) => handleUpdate(index, update)}
|
|
||||||
onDelete={() => handleDelete(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HStack>
|
|
||||||
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
|
|
||||||
Add DNS Override
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DnsOverrideRowProps {
|
|
||||||
override: DnsOverride;
|
|
||||||
onUpdate: (update: Partial<DnsOverride>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
|
|
||||||
const ipv4Value = override.ipv4.join(", ");
|
|
||||||
const ipv6Value = override.ipv6.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title={override.enabled ? "Disable override" : "Enable override"}
|
|
||||||
checked={override.enabled ?? true}
|
|
||||||
onChange={(enabled) => onUpdate({ enabled })}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="Hostname"
|
|
||||||
placeholder="api.example.com"
|
|
||||||
defaultValue={override.hostname}
|
|
||||||
onChange={(hostname) => onUpdate({ hostname })}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="IPv4 addresses"
|
|
||||||
placeholder="127.0.0.1"
|
|
||||||
defaultValue={ipv4Value}
|
|
||||||
onChange={(value) =>
|
|
||||||
onUpdate({
|
|
||||||
ipv4: value
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="IPv6 addresses"
|
|
||||||
placeholder="::1"
|
|
||||||
defaultValue={ipv6Value}
|
|
||||||
onChange={(value) =>
|
|
||||||
onUpdate({
|
|
||||||
ipv6: value
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
icon="trash"
|
|
||||||
title="Delete override"
|
|
||||||
onClick={onDelete}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DropMarker = memo(
|
|
||||||
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"absolute pointer-events-none z-50",
|
|
||||||
orientation === "horizontal" && "w-full",
|
|
||||||
orientation === "vertical" && "w-0 top-0 bottom-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"absolute bg-primary rounded-full",
|
|
||||||
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
|
|
||||||
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
() => true,
|
|
||||||
);
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type {
|
|
||||||
FormInput,
|
|
||||||
FormInputCheckbox,
|
|
||||||
FormInputEditor,
|
|
||||||
FormInputFile,
|
|
||||||
FormInputHttpRequest,
|
|
||||||
FormInputKeyValue,
|
|
||||||
FormInputSelect,
|
|
||||||
FormInputText,
|
|
||||||
JsonPrimitive,
|
|
||||||
} from "@yaakapp-internal/plugins";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
||||||
import { useRandomKey } from "../hooks/useRandomKey";
|
|
||||||
import { capitalize } from "../lib/capitalize";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import type { InputProps } from "./core/Input";
|
|
||||||
import { Input } from "./core/Input";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PairEditor } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { Markdown } from "./Markdown";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
|
|
||||||
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
|
|
||||||
const INPUT_SIZE = "sm";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
inputs: FormInput[] | undefined | null;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
data: T;
|
|
||||||
autocompleteFunctions?: boolean;
|
|
||||||
autocompleteVariables?: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
|
||||||
inputs,
|
|
||||||
data,
|
|
||||||
onChange,
|
|
||||||
autocompleteVariables,
|
|
||||||
autocompleteFunctions,
|
|
||||||
stateKey,
|
|
||||||
className,
|
|
||||||
disabled,
|
|
||||||
}: Props<T>) {
|
|
||||||
const setDataAttr = useCallback(
|
|
||||||
(name: string, value: JsonPrimitive) => {
|
|
||||||
onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });
|
|
||||||
},
|
|
||||||
[data, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormInputsStack
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
data={data}
|
|
||||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: FormInputsProps<T> & { className?: string }) {
|
|
||||||
return (
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"h-full overflow-auto",
|
|
||||||
"pr-1", // A bit of space between inputs and scrollbar
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FormInputs {...props} />
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormInputsProps<T> = Pick<
|
|
||||||
Props<T>,
|
|
||||||
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
|
|
||||||
> & {
|
|
||||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
|
||||||
inputs,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
setDataAttr,
|
|
||||||
data,
|
|
||||||
disabled,
|
|
||||||
}: FormInputsProps<T>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{inputs?.map((input, i) => {
|
|
||||||
if ("hidden" in input && input.hidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("disabled" in input && disabled != null) {
|
|
||||||
input.disabled = disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (input.type) {
|
|
||||||
case "select":
|
|
||||||
return (
|
|
||||||
<SelectArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name]
|
|
||||||
? String(data[input.name])
|
|
||||||
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "text":
|
|
||||||
return (
|
|
||||||
<TextArg
|
|
||||||
key={i + stateKey}
|
|
||||||
stateKey={stateKey}
|
|
||||||
arg={input}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables || false}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "editor":
|
|
||||||
return (
|
|
||||||
<EditorArg
|
|
||||||
key={i + stateKey}
|
|
||||||
stateKey={stateKey}
|
|
||||||
arg={input}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables || false}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "checkbox":
|
|
||||||
return (
|
|
||||||
<CheckboxArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={data[input.name] != null ? data[input.name] === true : false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "http_request":
|
|
||||||
return (
|
|
||||||
<HttpRequestArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "file":
|
|
||||||
return (
|
|
||||||
<FileArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
filePath={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "accordion":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={i + stateKey}>
|
|
||||||
<DetailsBanner
|
|
||||||
summary={input.label}
|
|
||||||
className={classNames("!mb-auto", disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
<div className="mt-3">
|
|
||||||
<FormInputsStack
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DetailsBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "h_stack":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
|
|
||||||
<FormInputs
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "banner":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Banner
|
|
||||||
key={i + stateKey}
|
|
||||||
color={input.color}
|
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
<FormInputsStack
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
case "markdown":
|
|
||||||
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
|
|
||||||
case "key_value":
|
|
||||||
return (
|
|
||||||
<KeyValueArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
stateKey={stateKey}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
// @ts-expect-error
|
|
||||||
throw new Error(`Invalid input type: ${input.type}`);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputText;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
autocompleteFunctions: boolean;
|
|
||||||
autocompleteVariables: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const props: InputProps = {
|
|
||||||
onChange,
|
|
||||||
name: arg.name,
|
|
||||||
multiLine: arg.multiLine,
|
|
||||||
className: arg.multiLine ? "min-h-[4rem]" : undefined,
|
|
||||||
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
|
||||||
required: !arg.optional,
|
|
||||||
disabled: arg.disabled,
|
|
||||||
help: arg.description,
|
|
||||||
type: arg.password ? "password" : "text",
|
|
||||||
label: arg.label ?? arg.name,
|
|
||||||
size: INPUT_SIZE,
|
|
||||||
hideLabel: arg.hideLabel ?? arg.label == null,
|
|
||||||
placeholder: arg.placeholder ?? undefined,
|
|
||||||
forceUpdateKey: stateKey,
|
|
||||||
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
|
|
||||||
stateKey,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
};
|
|
||||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
|
||||||
return <Input {...props} />;
|
|
||||||
}
|
|
||||||
return <PlainInput {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputEditor;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
autocompleteFunctions: boolean;
|
|
||||||
autocompleteVariables: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const id = `input-${arg.name}`;
|
|
||||||
|
|
||||||
// Read-only editor force refresh for every defaultValue change
|
|
||||||
// Should this be built into the <Editor/> component?
|
|
||||||
const [popoutKey, regeneratePopoutKey] = useRandomKey();
|
|
||||||
const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
||||||
>
|
|
||||||
{arg.label}
|
|
||||||
</Label>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"border border-border rounded-md overflow-hidden px-2 py-1",
|
|
||||||
"focus-within:border-border-focus",
|
|
||||||
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
|
|
||||||
)}
|
|
||||||
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
|
|
||||||
>
|
|
||||||
<Editor
|
|
||||||
id={id}
|
|
||||||
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
language={arg.language}
|
|
||||||
readOnly={arg.readOnly}
|
|
||||||
onChange={onChange}
|
|
||||||
hideGutter
|
|
||||||
heightMode="auto"
|
|
||||||
className="min-h-[3rem]"
|
|
||||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
||||||
placeholder={arg.placeholder ?? undefined}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
stateKey={stateKey}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
actions={
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
className="my-0.5 opacity-60 group-hover:opacity-100"
|
|
||||||
icon="expand"
|
|
||||||
title="Pop out to large editor"
|
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
id: "id",
|
|
||||||
size: "full",
|
|
||||||
title: arg.readOnly ? "View Value" : "Edit Value",
|
|
||||||
className: "!max-w-[50rem] !max-h-[60rem]",
|
|
||||||
description: arg.label && (
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
||||||
>
|
|
||||||
{arg.label}
|
|
||||||
</Label>
|
|
||||||
),
|
|
||||||
onClose() {
|
|
||||||
// Force the main editor to update on close
|
|
||||||
regeneratePopoutKey();
|
|
||||||
},
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Editor
|
|
||||||
id={id}
|
|
||||||
autocomplete={
|
|
||||||
arg.completionOptions ? { options: arg.completionOptions } : undefined
|
|
||||||
}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
language={arg.language}
|
|
||||||
readOnly={arg.readOnly}
|
|
||||||
onChange={onChange}
|
|
||||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
||||||
placeholder={arg.placeholder ?? undefined}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
stateKey={stateKey}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectArg({
|
|
||||||
arg,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputSelect;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
label={arg.label ?? arg.name}
|
|
||||||
name={arg.name}
|
|
||||||
help={arg.description}
|
|
||||||
onChange={onChange}
|
|
||||||
defaultValue={arg.defaultValue}
|
|
||||||
hideLabel={arg.hideLabel}
|
|
||||||
value={value}
|
|
||||||
size={INPUT_SIZE}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
options={arg.options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileArg({
|
|
||||||
arg,
|
|
||||||
filePath,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputFile;
|
|
||||||
filePath: string;
|
|
||||||
onChange: (v: string | null) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectFile
|
|
||||||
disabled={arg.disabled}
|
|
||||||
help={arg.description}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}
|
|
||||||
directory={!!arg.directory}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpRequestArg({
|
|
||||||
arg,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputHttpRequest;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const httpRequests = useAtomValue(httpRequestsAtom);
|
|
||||||
const activeHttpRequest = useActiveRequest("http_request");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
|
|
||||||
onChange(activeHttpRequest.id);
|
|
||||||
}
|
|
||||||
}, [activeHttpRequest, onChange, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
label={arg.label ?? arg.name}
|
|
||||||
name={arg.name}
|
|
||||||
onChange={onChange}
|
|
||||||
help={arg.description}
|
|
||||||
value={value}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
options={httpRequests.map((r) => {
|
|
||||||
return {
|
|
||||||
label:
|
|
||||||
buildRequestBreadcrumbs(r, folders).join(" / ") +
|
|
||||||
(r.id === activeHttpRequest?.id ? " (current)" : ""),
|
|
||||||
value: r.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
|
||||||
const ancestors: (HttpRequest | Folder)[] = [request];
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
const latest = ancestors[0];
|
|
||||||
if (latest == null) return [];
|
|
||||||
|
|
||||||
const parent = folders.find((f) => f.id === latest.folderId);
|
|
||||||
if (parent == null) return;
|
|
||||||
|
|
||||||
ancestors.unshift(parent);
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
next();
|
|
||||||
|
|
||||||
return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function CheckboxArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
arg: FormInputCheckbox;
|
|
||||||
value: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
onChange={onChange}
|
|
||||||
checked={value}
|
|
||||||
help={arg.description}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
title={arg.label ?? arg.name}
|
|
||||||
hideLabel={arg.label == null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyValueArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputKeyValue;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const pairs: Pair[] = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(newPairs: Pair[]) => {
|
|
||||||
onChange(JSON.stringify(newPairs));
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
|
||||||
<Label
|
|
||||||
htmlFor={`input-${arg.name}`}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
>
|
|
||||||
{arg.label ?? arg.name}
|
|
||||||
</Label>
|
|
||||||
<PairEditor
|
|
||||||
pairs={pairs}
|
|
||||||
onChange={handleChange}
|
|
||||||
stateKey={stateKey}
|
|
||||||
namePlaceholder="name"
|
|
||||||
valuePlaceholder="value"
|
|
||||||
noScroll
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
|
||||||
if (!inputs) return false;
|
|
||||||
|
|
||||||
for (const input of inputs) {
|
|
||||||
if ("inputs" in input && !hasVisibleInputs(input.inputs)) {
|
|
||||||
// Has children, but none are visible
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!input.hidden) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyStateText({ children, className }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full pb-2">
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"rounded-lg border border-dashed border-border-subtle",
|
|
||||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export function EncryptionHelp() {
|
|
||||||
return (
|
|
||||||
<VStack space={3}>
|
|
||||||
<p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>
|
|
||||||
<p>
|
|
||||||
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
|
|
||||||
sharing with others.
|
|
||||||
</p>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { editEnvironment } from "../lib/editEnvironment";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import type { DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
className?: string;
|
|
||||||
} & Pick<ButtonProps, "forDropdown" | "leftSlot">;
|
|
||||||
|
|
||||||
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
|
|
||||||
className,
|
|
||||||
...buttonProps
|
|
||||||
}: Props) {
|
|
||||||
const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();
|
|
||||||
const activeEnvironment = useActiveEnvironment();
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(
|
|
||||||
() => [
|
|
||||||
...subEnvironments.map(
|
|
||||||
(e) => ({
|
|
||||||
key: e.id,
|
|
||||||
label: e.name,
|
|
||||||
rightSlot: <EnvironmentColorIndicator environment={e} />,
|
|
||||||
leftSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
if (e.id !== activeEnvironment?.id) {
|
|
||||||
setWorkspaceSearchParams({ environment_id: e.id });
|
|
||||||
} else {
|
|
||||||
setWorkspaceSearchParams({ environment_id: null });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[activeEnvironment?.id],
|
|
||||||
),
|
|
||||||
...((subEnvironments.length > 0
|
|
||||||
? [{ type: "separator", label: "Environments" }]
|
|
||||||
: []) as DropdownItem[]),
|
|
||||||
{
|
|
||||||
label: "Manage Environments",
|
|
||||||
hotKeyAction: "environment_editor.toggle",
|
|
||||||
leftSlot: <Icon icon="box" />,
|
|
||||||
onSelect: () => editEnvironment(activeEnvironment),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[subEnvironments, activeEnvironment],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasBaseVars =
|
|
||||||
(baseEnvironment?.variables ?? []).filter((v) => v.enabled && (v.name || v.value)).length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"text !px-2 truncate",
|
|
||||||
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
|
|
||||||
)}
|
|
||||||
// If no environments, the button simply opens the dialog.
|
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
|
||||||
{...buttonProps}
|
|
||||||
>
|
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
|
||||||
{activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { showColorPicker } from "../lib/showColorPicker";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
|
|
||||||
export function EnvironmentColorIndicator({
|
|
||||||
environment,
|
|
||||||
clickToEdit,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
environment: Environment | null;
|
|
||||||
clickToEdit?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
if (environment?.color == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorIndicator
|
|
||||||
className={className}
|
|
||||||
color={environment?.color ?? null}
|
|
||||||
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
|
||||||
|
|
||||||
export function EnvironmentColorPicker({
|
|
||||||
color: defaultColor,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
color: string | null;
|
|
||||||
onChange: (color: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const [color, setColor] = useState<string | null>(defaultColor);
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex flex-col items-stretch gap-5 pb-2 w-full"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onChange(color);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Banner color="secondary">
|
|
||||||
This color will be used to color the interface when this environment is active
|
|
||||||
</Banner>
|
|
||||||
<ColorPickerWithThemeColors color={color} onChange={setColor} />
|
|
||||||
<Button type="submit" color="secondary">
|
|
||||||
{color != null && <ColorIndicator color={color} />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
|
||||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import { atomFamily } from "jotai-family";
|
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import {
|
|
||||||
environmentsBreakdownAtom,
|
|
||||||
useEnvironmentsBreakdown,
|
|
||||||
} from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { showColorPicker } from "../lib/showColorPicker";
|
|
||||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
|
||||||
import { ContextMenu } from "./core/Dropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import type { PairEditorHandle } from "./core/PairEditor";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
|
||||||
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
|
|
||||||
|
|
||||||
const collapsedFamily = atomFamily((treeId: string) => {
|
|
||||||
const key = ["env_collapsed", treeId ?? "n/a"];
|
|
||||||
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialEnvironmentId: string | null;
|
|
||||||
setRef?: (ref: PairEditorHandle | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreeModel = Environment | Workspace;
|
|
||||||
|
|
||||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
|
||||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
|
||||||
initialEnvironmentId ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedEnvironment =
|
|
||||||
selectedEnvironmentId != null
|
|
||||||
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
|
|
||||||
: baseEnvironment;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="env_editor"
|
|
||||||
defaultRatio={0.75}
|
|
||||||
layout="horizontal"
|
|
||||||
className="gap-0"
|
|
||||||
resizeHandleClassName="-translate-x-[1px]"
|
|
||||||
firstSlot={() => (
|
|
||||||
<EnvironmentEditDialogSidebar
|
|
||||||
selectedEnvironmentId={selectedEnvironment?.id ?? null}
|
|
||||||
setSelectedEnvironmentId={setSelectedEnvironmentId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={() => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
{baseEnvironments.length > 1 ? (
|
|
||||||
<div className="p-3">
|
|
||||||
<Banner color="notice">
|
|
||||||
There are multiple base environments for this workspace. Please delete the
|
|
||||||
environments you no longer need.
|
|
||||||
</Banner>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
{selectedEnvironment == null ? (
|
|
||||||
<div className="p-3 mt-10">
|
|
||||||
<Banner color="danger">
|
|
||||||
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
|
|
||||||
</Banner>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EnvironmentEditor
|
|
||||||
key={selectedEnvironment.id}
|
|
||||||
setRef={setRef}
|
|
||||||
className="pl-4 pt-3"
|
|
||||||
environment={selectedEnvironment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharableTooltip = (
|
|
||||||
<IconTooltip
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="eye"
|
|
||||||
iconSize="sm"
|
|
||||||
content="This environment will be included in Directory Sync and data exports"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
function EnvironmentEditDialogSidebar({
|
|
||||||
selectedEnvironmentId,
|
|
||||||
setSelectedEnvironmentId,
|
|
||||||
}: {
|
|
||||||
selectedEnvironmentId: string | null;
|
|
||||||
setSelectedEnvironmentId: (id: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
|
||||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
|
||||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (selectedEnvironmentId == null) return;
|
|
||||||
treeRef.current?.selectItem(selectedEnvironmentId);
|
|
||||||
treeRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDeleteEnvironment = useCallback(
|
|
||||||
async (environment: Environment) => {
|
|
||||||
await deleteModelWithConfirm(environment);
|
|
||||||
if (selectedEnvironmentId === environment.id) {
|
|
||||||
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
|
||||||
|
|
||||||
const getSelectedTreeModels = useCallback(
|
|
||||||
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRenameSelected = useCallback(() => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items?.length === 1 && items[0] != null) {
|
|
||||||
treeRef.current?.renameItem(items[0].id);
|
|
||||||
}
|
|
||||||
}, [getSelectedTreeModels]);
|
|
||||||
|
|
||||||
const handleDeleteSelected = useCallback(
|
|
||||||
(items: TreeModel[]) => deleteModelWithConfirm(items),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDuplicateSelected = useCallback(
|
|
||||||
async (items: TreeModel[]) => {
|
|
||||||
if (items.length === 1 && items[0]) {
|
|
||||||
const newId = await duplicateModel(items[0]);
|
|
||||||
setSelectedEnvironmentId(newId);
|
|
||||||
} else {
|
|
||||||
await Promise.all(items.map(duplicateModel));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey("sidebar.selected.rename", handleRenameSelected, {
|
|
||||||
enable: treeHasFocus,
|
|
||||||
allowDefault: true,
|
|
||||||
priority: 100,
|
|
||||||
});
|
|
||||||
useHotKey(
|
|
||||||
"sidebar.selected.delete",
|
|
||||||
useCallback(() => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items) {
|
|
||||||
fireAndForget(handleDeleteSelected(items));
|
|
||||||
}
|
|
||||||
}, [getSelectedTreeModels, handleDeleteSelected]),
|
|
||||||
{ enable: treeHasFocus, priority: 100 },
|
|
||||||
);
|
|
||||||
useHotKey(
|
|
||||||
"sidebar.selected.duplicate",
|
|
||||||
useCallback(async () => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items) await handleDuplicateSelected(items);
|
|
||||||
}, [getSelectedTreeModels, handleDuplicateSelected]),
|
|
||||||
{ enable: treeHasFocus, priority: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const getContextMenu = useCallback(
|
|
||||||
(items: TreeModel[]): ContextMenuProps["items"] => {
|
|
||||||
const environment = items[0];
|
|
||||||
const addEnvironmentItem: DropdownItem = {
|
|
||||||
label: "Create Sub Environment",
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
await createSubEnvironment();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (environment == null || environment.model !== "environment") {
|
|
||||||
return [addEnvironmentItem];
|
|
||||||
}
|
|
||||||
|
|
||||||
const singleEnvironment = items.length === 1;
|
|
||||||
const canDeleteEnvironment =
|
|
||||||
isSubEnvironment(environment) ||
|
|
||||||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
|
|
||||||
|
|
||||||
const menuItems: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
label: "Rename",
|
|
||||||
leftSlot: <Icon icon="pencil" />,
|
|
||||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
|
||||||
hotKeyAction: "sidebar.selected.rename",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
onSelect: () => {
|
|
||||||
// Not sure why this is needed, but without it the
|
|
||||||
// edit input blurs immediately after opening.
|
|
||||||
requestAnimationFrame(() => handleRenameSelected());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Duplicate",
|
|
||||||
leftSlot: <Icon icon="copy" />,
|
|
||||||
hidden: isBaseEnvironment(environment),
|
|
||||||
hotKeyAction: "sidebar.selected.duplicate",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
onSelect: () => handleDuplicateSelected(items),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: environment.color ? "Change Color" : "Assign Color",
|
|
||||||
leftSlot: <Icon icon="palette" />,
|
|
||||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
|
||||||
onSelect: async () => showColorPicker(environment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Make ${environment.public ? "Private" : "Sharable"}`,
|
|
||||||
leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
|
|
||||||
rightSlot: <EnvironmentSharableTooltip />,
|
|
||||||
hidden: items.length > 1,
|
|
||||||
onSelect: async () => {
|
|
||||||
await patchModel(environment, { public: !environment.public });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: "danger",
|
|
||||||
label: "Delete",
|
|
||||||
hotKeyAction: "sidebar.selected.delete",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
hidden: !canDeleteEnvironment,
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: () => handleDeleteEnvironment(environment),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add sub environment to base environment
|
|
||||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
|
||||||
menuItems.push({ type: "separator" });
|
|
||||||
menuItems.push(addEnvironmentItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItems;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
baseEnvironments.length,
|
|
||||||
handleDeleteEnvironment,
|
|
||||||
handleDuplicateSelected,
|
|
||||||
handleRenameSelected,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
|
||||||
items,
|
|
||||||
children,
|
|
||||||
insertAt,
|
|
||||||
}: {
|
|
||||||
items: TreeModel[];
|
|
||||||
children: TreeModel[];
|
|
||||||
insertAt: number;
|
|
||||||
}) {
|
|
||||||
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
|
|
||||||
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
|
|
||||||
|
|
||||||
const beforePriority = prev?.sortPriority ?? 0;
|
|
||||||
const afterPriority = next?.sortPriority ?? 0;
|
|
||||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (shouldUpdateAll) {
|
|
||||||
// Add items to children at insertAt
|
|
||||||
children.splice(insertAt, 0, ...items);
|
|
||||||
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
|
|
||||||
} else {
|
|
||||||
const range = afterPriority - beforePriority;
|
|
||||||
const increment = range / (items.length + 2);
|
|
||||||
await Promise.all(
|
|
||||||
items.map((m, i) => {
|
|
||||||
const sortPriority = beforePriority + (i + 1) * increment;
|
|
||||||
// Spread item sortPriority out over before/after range
|
|
||||||
return patchModel(m, { sortPriority });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleActivate = useCallback(
|
|
||||||
(item: TreeModel) => {
|
|
||||||
setSelectedEnvironmentId(item.id);
|
|
||||||
},
|
|
||||||
[setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
|
|
||||||
({ items, position, onClose }) => (
|
|
||||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tree = useAtomValue(treeAtom);
|
|
||||||
return (
|
|
||||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
|
||||||
{tree != null && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<Tree
|
|
||||||
ref={treeRef}
|
|
||||||
treeId={treeId}
|
|
||||||
collapsedAtom={collapsedFamily(treeId)}
|
|
||||||
className="px-2 pb-10"
|
|
||||||
root={tree}
|
|
||||||
getContextMenu={getContextMenu}
|
|
||||||
renderContextMenu={renderContextMenuFn}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
|
||||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
|
||||||
ItemRightSlot={ItemRightSlot}
|
|
||||||
ItemInner={ItemInner}
|
|
||||||
onActivate={handleActivate}
|
|
||||||
getEditOptions={getEditOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
|
||||||
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
|
|
||||||
if (activeWorkspace == null || baseEnvironment == null) return null;
|
|
||||||
|
|
||||||
const root: TreeNode<TreeModel> = {
|
|
||||||
item: activeWorkspace,
|
|
||||||
parent: null,
|
|
||||||
children: [],
|
|
||||||
depth: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const item of baseEnvironments) {
|
|
||||||
root.children?.push({
|
|
||||||
item,
|
|
||||||
parent: root,
|
|
||||||
depth: 0,
|
|
||||||
draggable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = root.children?.[0];
|
|
||||||
if (baseEnvironments.length <= 1 && parent != null) {
|
|
||||||
parent.children = subEnvironments.map((item) => ({
|
|
||||||
item,
|
|
||||||
parent,
|
|
||||||
depth: 1,
|
|
||||||
localDrag: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
});
|
|
||||||
|
|
||||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
|
||||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
return baseEnvironments.length > 1 ? (
|
|
||||||
<Icon icon="alert_triangle" color="notice" />
|
|
||||||
) : (
|
|
||||||
item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
|
||||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
color="custom"
|
|
||||||
iconSize="sm"
|
|
||||||
icon="plus_circle"
|
|
||||||
className="opacity-50 hover:opacity-100"
|
|
||||||
title="Add Sub-Environment"
|
|
||||||
onClick={createSubEnvironment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemInner({ item }: { item: TreeModel }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
|
|
||||||
{item.model === "environment" && item.public ? (
|
|
||||||
<div className="mr-2 flex items-center">{sharableTooltip}</div>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSubEnvironment() {
|
|
||||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
|
||||||
if (baseEnvironment == null) return;
|
|
||||||
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditOptions(item: TreeModel) {
|
|
||||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
|
||||||
defaultValue: item.name,
|
|
||||||
placeholder: "Name",
|
|
||||||
async onChange(item, name) {
|
|
||||||
await patchModel(item, { name });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { Heading } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useIsEncryptionEnabled } from "../hooks/useIsEncryptionEnabled";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { useRandomKey } from "../hooks/useRandomKey";
|
|
||||||
import { analyzeTemplate, convertTemplateToSecure } from "../lib/encryption";
|
|
||||||
import { isBaseEnvironment } from "../lib/model_util";
|
|
||||||
import {
|
|
||||||
setupOrConfigureEncryption,
|
|
||||||
withEncryptionEnabled,
|
|
||||||
} from "../lib/setupOrConfigureEncryption";
|
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
|
||||||
import { ensurePairId } from "./core/PairEditor.util";
|
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
environment: Environment;
|
|
||||||
hideName?: boolean;
|
|
||||||
className?: string;
|
|
||||||
setRef?: (n: PairEditorHandle | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
|
|
||||||
const workspaceId = environment.workspaceId;
|
|
||||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
|
||||||
const valueVisibility = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
|
||||||
key: ["environmentValueVisibility", workspaceId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const { allEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(variables: PairWithId[]) => patchModel(environment, { variables }),
|
|
||||||
[environment],
|
|
||||||
);
|
|
||||||
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
|
|
||||||
|
|
||||||
// Gather a list of env names from other environments to help the user get them aligned
|
|
||||||
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
|
|
||||||
const options: GenericCompletionOption[] = [];
|
|
||||||
if (isBaseEnvironment(environment)) {
|
|
||||||
return { options };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allVariables = allEnvironments.flatMap((e) => e?.variables);
|
|
||||||
const allVariableNames = new Set(allVariables.map((v) => v?.name));
|
|
||||||
for (const name of allVariableNames) {
|
|
||||||
const containingEnvs = allEnvironments.filter((e) =>
|
|
||||||
e.variables.some((v) => v.name === name),
|
|
||||||
);
|
|
||||||
const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);
|
|
||||||
if (isAlreadyInActive) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
options.push({
|
|
||||||
label: name,
|
|
||||||
type: "constant",
|
|
||||||
detail: containingEnvs.map((e) => e.name).join(", "),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { options };
|
|
||||||
}, [environment, allEnvironments]);
|
|
||||||
|
|
||||||
const validateName = useCallback((name: string) => {
|
|
||||||
// Empty just means the variable doesn't have a name yet and is unusable
|
|
||||||
if (name === "") return true;
|
|
||||||
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
|
|
||||||
const allVariableAreEncrypted = useMemo(
|
|
||||||
() =>
|
|
||||||
environment.variables.every((v) => v.value === "" || analyzeTemplate(v.value) !== "insecure"),
|
|
||||||
[environment.variables],
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptEnvironment = (environment: Environment) => {
|
|
||||||
withEncryptionEnabled(async () => {
|
|
||||||
const encryptedVariables: PairWithId[] = [];
|
|
||||||
for (const variable of environment.variables) {
|
|
||||||
const value = variable.value ? await convertTemplateToSecure(variable.value) : "";
|
|
||||||
encryptedVariables.push(ensurePairId({ ...variable, value }));
|
|
||||||
}
|
|
||||||
await handleChange(encryptedVariables);
|
|
||||||
regenerateForceUpdateKey();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Heading className="w-full flex items-center gap-0.5">
|
|
||||||
<EnvironmentColorIndicator
|
|
||||||
className="mr-2"
|
|
||||||
clickToEdit
|
|
||||||
environment={environment ?? null}
|
|
||||||
/>
|
|
||||||
{!hideName && <div className="mr-2">{environment?.name}</div>}
|
|
||||||
{isEncryptionEnabled ? (
|
|
||||||
!allVariableAreEncrypted ? (
|
|
||||||
<PillButton color="notice" onClick={() => encryptEnvironment(environment)}>
|
|
||||||
Encrypt All Variables
|
|
||||||
</PillButton>
|
|
||||||
) : (
|
|
||||||
<PillButton color="secondary" onClick={setupOrConfigureEncryption}>
|
|
||||||
Encryption Settings
|
|
||||||
</PillButton>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
|
|
||||||
{valueVisibility.value ? "Hide Values" : "Show Values"}
|
|
||||||
</PillButton>
|
|
||||||
)}
|
|
||||||
<PillButton
|
|
||||||
color="secondary"
|
|
||||||
rightSlot={<EnvironmentSharableTooltip />}
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(environment, { public: !environment.public });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{environment.public ? "Sharable" : "Private"}
|
|
||||||
</PillButton>
|
|
||||||
</Heading>
|
|
||||||
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
|
|
||||||
<DismissibleBanner
|
|
||||||
id={`warn-unencrypted-${environment.id}`}
|
|
||||||
color="notice"
|
|
||||||
className="mr-3"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Encrypt Variables",
|
|
||||||
onClick: () => encryptEnvironment(environment),
|
|
||||||
color: "success",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
This sharable environment contains plain-text secrets
|
|
||||||
</DismissibleBanner>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<PairOrBulkEditor
|
|
||||||
setRef={setRef}
|
|
||||||
className="h-full"
|
|
||||||
allowMultilineValues
|
|
||||||
preferenceName="environment"
|
|
||||||
nameAutocomplete={nameAutocomplete}
|
|
||||||
namePlaceholder="VAR_NAME"
|
|
||||||
nameValidate={validateName}
|
|
||||||
valueType={valueType}
|
|
||||||
valueAutocompleteVariables="environment"
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
|
|
||||||
pairs={environment.variables}
|
|
||||||
onChange={handleChange}
|
|
||||||
stateKey={`environment.${environment.id}`}
|
|
||||||
forcedEnvironmentId={environment.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
|
|
||||||
export function EnvironmentSharableTooltip() {
|
|
||||||
return (
|
|
||||||
<IconTooltip content="Sharable environments are included in Directory Sync and data export." />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
|
||||||
import { Component, useEffect } from "react";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import RouteError from "./RouteError";
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
name: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
||||||
constructor(props: ErrorBoundaryProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
||||||
console.warn("Error caught by ErrorBoundary:", error, info);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
|
||||||
<div>
|
|
||||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="inline-flex"
|
|
||||||
variant="border"
|
|
||||||
color="danger"
|
|
||||||
size="2xs"
|
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
id: "error-boundary",
|
|
||||||
render: () => <RouteError error={this.state.error} />,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Show
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundaryTestThrow() {
|
|
||||||
useEffect(() => {
|
|
||||||
throw new Error("test error");
|
|
||||||
});
|
|
||||||
|
|
||||||
return <div>Hello</div>;
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import slugify from "slugify";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onHide: () => void;
|
|
||||||
onSuccess: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportDataDialog({ onHide, onSuccess }: Props) {
|
|
||||||
const allWorkspaces = useAtomValue(workspacesAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
if (activeWorkspace == null || allWorkspaces.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExportDataDialogContent
|
|
||||||
onHide={onHide}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
allWorkspaces={allWorkspaces}
|
|
||||||
activeWorkspace={activeWorkspace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExportDataDialogContent({
|
|
||||||
onHide,
|
|
||||||
onSuccess,
|
|
||||||
activeWorkspace,
|
|
||||||
allWorkspaces,
|
|
||||||
}: Props & {
|
|
||||||
allWorkspaces: Workspace[];
|
|
||||||
activeWorkspace: Workspace;
|
|
||||||
}) {
|
|
||||||
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
|
|
||||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
|
|
||||||
[activeWorkspace.id]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Put the active workspace first
|
|
||||||
const workspaces = useMemo(
|
|
||||||
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
|
|
||||||
[activeWorkspace, allWorkspaces],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleAll = () => {
|
|
||||||
setSelectedWorkspaces(
|
|
||||||
// oxlint-disable-next-line no-accumulating-spread
|
|
||||||
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
|
||||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
|
||||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
|
||||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
|
||||||
const exportPath = await save({
|
|
||||||
title: "Export Data",
|
|
||||||
defaultPath: `yaak.${slug}.json`,
|
|
||||||
});
|
|
||||||
if (exportPath == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await invokeCmd("cmd_export_data", {
|
|
||||||
workspaceIds: ids,
|
|
||||||
exportPath,
|
|
||||||
includePrivateEnvironments: includePrivateEnvironments,
|
|
||||||
});
|
|
||||||
onHide();
|
|
||||||
onSuccess(exportPath);
|
|
||||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
|
||||||
|
|
||||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
|
||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
|
||||||
const noneSelected = numSelected === 0;
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
|
||||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="w-6 min-w-0 py-2 text-left pl-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={!allSelected && !noneSelected ? "indeterminate" : allSelected}
|
|
||||||
hideLabel
|
|
||||||
title="All workspaces"
|
|
||||||
onChange={handleToggleAll}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
|
|
||||||
Workspace
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{workspaces.map((w) => (
|
|
||||||
<tr key={w.id}>
|
|
||||||
<td className="min-w-0 py-1 pl-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedWorkspaces[w.id] ?? false}
|
|
||||||
title={w.name}
|
|
||||||
hideLabel
|
|
||||||
onChange={() =>
|
|
||||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{w.name} {w.id === activeWorkspace.id ? "(current workspace)" : ""}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<DetailsBanner color="secondary" defaultOpen summary="Extra Settings">
|
|
||||||
<Checkbox
|
|
||||||
checked={includePrivateEnvironments}
|
|
||||||
onChange={setIncludePrivateEnvironments}
|
|
||||||
title="Include private environments"
|
|
||||||
help='Environments marked as "sharable" will be exported by default'
|
|
||||||
/>
|
|
||||||
</DetailsBanner>
|
|
||||||
</VStack>
|
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
|
||||||
<div>
|
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
|
||||||
Create Run Button
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
className="focus"
|
|
||||||
color="primary"
|
|
||||||
disabled={noneSelected}
|
|
||||||
onClick={() => handleExport()}
|
|
||||||
>
|
|
||||||
Export{" "}
|
|
||||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { foldersAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useFolderActions } from "../hooks/useFolderActions";
|
|
||||||
import { useLatestHttpResponse } from "../hooks/useLatestHttpResponse";
|
|
||||||
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Separator } from "./core/Separator";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import { HttpResponsePane } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folder: Folder;
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderLayout({ folder, style }: Props) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const requests = useAtomValue(allRequestsAtom);
|
|
||||||
const folderActions = useFolderActions();
|
|
||||||
const sendAllAction = useMemo(
|
|
||||||
() => folderActions.find((a) => a.label === "Send All"),
|
|
||||||
[folderActions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const children = useMemo(() => {
|
|
||||||
return [
|
|
||||||
...folders.filter((f) => f.folderId === folder.id),
|
|
||||||
...requests.filter((r) => r.folderId === folder.id),
|
|
||||||
];
|
|
||||||
}, [folder.id, folders, requests]);
|
|
||||||
|
|
||||||
const handleSendAll = useCallback(() => {
|
|
||||||
void sendAllAction?.call(folder);
|
|
||||||
}, [sendAllAction, folder]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
|
|
||||||
<HStack space={2} alignItems="center">
|
|
||||||
<Icon icon="folder" size="xl" color="secondary" />
|
|
||||||
<Heading level={1}>{resolvedModelName(folder)}</Heading>
|
|
||||||
<HStack className="ml-auto" alignItems="center">
|
|
||||||
<Button
|
|
||||||
rightSlot={<Icon icon="send_horizontal" />}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
onClick={handleSendAll}
|
|
||||||
disabled={sendAllAction == null}
|
|
||||||
>
|
|
||||||
Send All
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Separator className="mt-3 mb-8" />
|
|
||||||
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
|
|
||||||
{children.map((child) => (
|
|
||||||
<ChildCard key={child.id} child={child} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
|
||||||
let card: ReactNode;
|
|
||||||
if (child.model === "folder") {
|
|
||||||
card = <FolderCard folder={child} />;
|
|
||||||
} else if (child.model === "http_request") {
|
|
||||||
card = <HttpRequestCard request={child} />;
|
|
||||||
} else if (child.model === "grpc_request") {
|
|
||||||
card = <RequestCard request={child} />;
|
|
||||||
} else if (child.model === "websocket_request") {
|
|
||||||
card = <RequestCard request={child} />;
|
|
||||||
} else {
|
|
||||||
card = <div>Unknown model</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigate = useCallback(async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: child.workspaceId },
|
|
||||||
search: (prev) => ({ ...prev, request_id: child.id }),
|
|
||||||
});
|
|
||||||
}, [child.id, child.workspaceId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"rounded-lg bg-surface-highlight p-3 pt-1 border border-border",
|
|
||||||
"flex flex-col gap-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HStack space={2}>
|
|
||||||
{child.model === "folder" && <Icon icon="folder" size="lg" />}
|
|
||||||
<Heading className="truncate" level={2}>
|
|
||||||
{resolvedModelName(child)}
|
|
||||||
</Heading>
|
|
||||||
<HStack space={0.5} className="ml-auto -mr-1.5">
|
|
||||||
<IconButton
|
|
||||||
color="custom"
|
|
||||||
title="Send Request"
|
|
||||||
size="sm"
|
|
||||||
icon="external_link"
|
|
||||||
className="opacity-70 hover:opacity-100"
|
|
||||||
onClick={navigate}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
color="custom"
|
|
||||||
title="Send Request"
|
|
||||||
size="sm"
|
|
||||||
icon="send_horizontal"
|
|
||||||
className="opacity-70 hover:opacity-100"
|
|
||||||
onClick={() => {
|
|
||||||
sendAnyHttpRequest.mutate(child.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<div className="text-text-subtle">{card}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderCard({ folder }: { folder: Folder }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: folder.workspaceId },
|
|
||||||
search: (prev) => {
|
|
||||||
return { ...prev, request_id: null, folder_id: folder.id };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
|
|
||||||
return <div>TODO {request.id}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpRequestCard({ request }: { request: HttpRequest }) {
|
|
||||||
const latestResponse = useLatestHttpResponse(request.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
|
||||||
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
|
||||||
{request.method} {request.url}
|
|
||||||
</code>
|
|
||||||
{latestResponse ? (
|
|
||||||
<button
|
|
||||||
className="block mr-auto"
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showDialog({
|
|
||||||
id: "response-preview",
|
|
||||||
title: "Response Preview",
|
|
||||||
size: "md",
|
|
||||||
className: "h-full",
|
|
||||||
render: () => {
|
|
||||||
return <HttpResponsePane activeRequestId={request.id} />;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
space={2}
|
|
||||||
alignItems="center"
|
|
||||||
className={classNames(
|
|
||||||
"cursor-default select-none",
|
|
||||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
|
||||||
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
|
||||||
<HttpStatusTag showReason response={latestResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<HttpResponseDurationTag response={latestResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<SizeTag
|
|
||||||
contentLength={latestResponse.contentLength ?? 0}
|
|
||||||
contentLengthCompressed={latestResponse.contentLength}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div>No Responses</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Fragment, useMemo } from "react";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|
||||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { hideDialog } from "../lib/dialog";
|
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { Input } from "./core/Input";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folderId: string | null;
|
|
||||||
tab?: FolderSettingsTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_VARIABLES = "variables";
|
|
||||||
const TAB_GENERAL = "general";
|
|
||||||
|
|
||||||
export type FolderSettingsTab =
|
|
||||||
| typeof TAB_AUTH
|
|
||||||
| typeof TAB_HEADERS
|
|
||||||
| typeof TAB_GENERAL
|
|
||||||
| typeof TAB_VARIABLES;
|
|
||||||
|
|
||||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
|
||||||
const ancestors = useModelAncestors(folder);
|
|
||||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
|
||||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
|
||||||
const inheritedHeaders = useInheritedHeaders(folder);
|
|
||||||
const environments = useEnvironmentsBreakdown();
|
|
||||||
const folderEnvironment = environments.allEnvironments.find(
|
|
||||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
|
||||||
);
|
|
||||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
|
||||||
if (folder == null) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: TAB_GENERAL,
|
|
||||||
label: "General",
|
|
||||||
},
|
|
||||||
...headersTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_VARIABLES,
|
|
||||||
label: "Variables",
|
|
||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [authTab, folder, headersTab, numVars]);
|
|
||||||
|
|
||||||
if (folder == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
|
||||||
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
|
||||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
|
||||||
{breadcrumbs.map((item, index) => (
|
|
||||||
<Fragment key={item.id}>
|
|
||||||
{index > 0 && (
|
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
{breadcrumbs.length > 0 && (
|
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="whitespace-nowrap" title={folder.name}>
|
|
||||||
{folder.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={tab ?? TAB_GENERAL}
|
|
||||||
label="Folder Settings"
|
|
||||||
className="pt-2 pb-2 pl-3 pr-1 flex-1"
|
|
||||||
layout="horizontal"
|
|
||||||
addBorders
|
|
||||||
tabs={tabs}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
|
||||||
<HttpAuthenticationEditor model={folder} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
|
|
||||||
<Input
|
|
||||||
label="Folder Name"
|
|
||||||
defaultValue={folder.name}
|
|
||||||
onChange={(name) => patchModel(folder, { name })}
|
|
||||||
stateKey={`name.${folder.id}`}
|
|
||||||
/>
|
|
||||||
<MarkdownEditor
|
|
||||||
name="folder-description"
|
|
||||||
placeholder="Folder description"
|
|
||||||
className="border border-border px-2"
|
|
||||||
defaultValue={folder.description}
|
|
||||||
stateKey={`description.${folder.id}`}
|
|
||||||
onChange={(description) => patchModel(folder, { description })}
|
|
||||||
/>
|
|
||||||
<HStack alignItems="center" justifyContent="between" className="w-full">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
const didDelete = await deleteModelWithConfirm(folder);
|
|
||||||
if (didDelete) {
|
|
||||||
hideDialog("folder-settings");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Delete Folder
|
|
||||||
</Button>
|
|
||||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
|
||||||
{folder.id}
|
|
||||||
<CopyIconButton
|
|
||||||
className="opacity-70 !text-primary"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Copy folder ID"
|
|
||||||
text={folder.id}
|
|
||||||
/>
|
|
||||||
</InlineCode>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
|
||||||
<HeadersEditor
|
|
||||||
inheritedHeaders={inheritedHeaders}
|
|
||||||
forceUpdateKey={folder.id}
|
|
||||||
headers={folder.headers}
|
|
||||||
onChange={(headers) => patchModel(folder, { headers })}
|
|
||||||
stateKey={`headers.${folder.id}`}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
|
||||||
{folderEnvironment == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
<VStack alignItems="center" space={1.5}>
|
|
||||||
<p>
|
|
||||||
Override{" "}
|
|
||||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
|
||||||
Variables
|
|
||||||
</Link>{" "}
|
|
||||||
for requests within this folder.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await createWorkspaceModel({
|
|
||||||
workspaceId: folder.workspaceId,
|
|
||||||
parentModel: "folder",
|
|
||||||
parentId: folder.id,
|
|
||||||
model: "environment",
|
|
||||||
name: "Folder Environment",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Folder Environment
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
|
||||||
)}
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
|
||||||
import { PairEditor } from "./core/PairEditor";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
forceUpdateKey: string;
|
|
||||||
request: HttpRequest;
|
|
||||||
onChange: (body: HttpRequest["body"]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
|
|
||||||
const pairs = useMemo<Pair[]>(
|
|
||||||
() =>
|
|
||||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
|
||||||
enabled: p.enabled,
|
|
||||||
name: p.name,
|
|
||||||
value: p.file ?? p.value,
|
|
||||||
contentType: p.contentType,
|
|
||||||
filename: p.filename,
|
|
||||||
isFile: !!p.file,
|
|
||||||
id: p.id,
|
|
||||||
})),
|
|
||||||
[request.body.form],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
|
||||||
(pairs) =>
|
|
||||||
onChange({
|
|
||||||
form: pairs.map((p) => ({
|
|
||||||
enabled: p.enabled,
|
|
||||||
name: p.name,
|
|
||||||
contentType: p.contentType,
|
|
||||||
filename: p.filename,
|
|
||||||
file: p.isFile ? p.value : undefined,
|
|
||||||
value: p.isFile ? undefined : p.value,
|
|
||||||
id: p.id,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PairEditor
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
nameAutocompleteVariables
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
allowFileValues
|
|
||||||
allowMultilineValues
|
|
||||||
pairs={pairs}
|
|
||||||
onChange={handleChange}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
stateKey={`multipart.${request.id}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
forceUpdateKey: string;
|
|
||||||
request: HttpRequest;
|
|
||||||
onChange: (headers: HttpRequest["body"]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
|
|
||||||
const pairs = useMemo<Pair[]>(
|
|
||||||
() =>
|
|
||||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
|
||||||
enabled: !!p.enabled,
|
|
||||||
name: p.name || "",
|
|
||||||
value: p.value || "",
|
|
||||||
id: p.id,
|
|
||||||
})),
|
|
||||||
[request.body.form],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
|
||||||
(pairs) =>
|
|
||||||
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PairOrBulkEditor
|
|
||||||
allowMultilineValues
|
|
||||||
preferenceName="form_urlencoded"
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
nameAutocompleteVariables
|
|
||||||
namePlaceholder="entry_name"
|
|
||||||
valuePlaceholder="Value"
|
|
||||||
pairs={pairs}
|
|
||||||
onChange={handleChange}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
stateKey={`urlencoded.${request.id}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
|
||||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
|
||||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
|
||||||
import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
|
||||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
|
||||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
|
||||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
|
||||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
|
||||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
|
||||||
|
|
||||||
export function GlobalHooks() {
|
|
||||||
useSyncZoomSetting();
|
|
||||||
useSyncFontSizeSetting();
|
|
||||||
|
|
||||||
useSubscribeActiveWorkspaceId();
|
|
||||||
|
|
||||||
useSyncWorkspaceChildModels();
|
|
||||||
useSubscribeTemplateFunctions();
|
|
||||||
useSubscribeHttpAuthentication();
|
|
||||||
|
|
||||||
// Other useful things
|
|
||||||
useActiveWorkspaceChangedToast();
|
|
||||||
useSubscribeHotKeys();
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
"request.rename",
|
|
||||||
async () => {
|
|
||||||
const model = jotaiStore.get(activeRequestAtom);
|
|
||||||
if (model == null) return;
|
|
||||||
await renameModelWithPrompt(model);
|
|
||||||
},
|
|
||||||
{ allowDefault: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
||||||
import { useGrpc } from "../hooks/useGrpc";
|
|
||||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
|
||||||
import { activeGrpcConnectionAtom, useGrpcEvents } from "../hooks/usePinnedGrpcConnection";
|
|
||||||
import { Banner, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { GrpcRequestPane } from "./GrpcRequestPane";
|
|
||||||
import { GrpcResponsePane } from "./GrpcResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyArray: string[] = [];
|
|
||||||
|
|
||||||
export function GrpcConnectionLayout({ style }: Props) {
|
|
||||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const wsId = activeWorkspace?.id ?? "n/a";
|
|
||||||
const activeRequest = useActiveRequest("grpc_request");
|
|
||||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
|
||||||
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
|
|
||||||
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
|
|
||||||
const protoFiles = protoFilesKv.value ?? emptyArray;
|
|
||||||
const grpc = useGrpc(activeRequest, activeConnection, protoFiles);
|
|
||||||
|
|
||||||
const services = grpc.reflect.data ?? null;
|
|
||||||
useEffect(() => {
|
|
||||||
if (services == null || activeRequest == null) return;
|
|
||||||
const s = services.find((s) => s.name === activeRequest.service);
|
|
||||||
if (s == null) {
|
|
||||||
patchModel(activeRequest, {
|
|
||||||
service: services[0]?.name ?? null,
|
|
||||||
method: services[0]?.methods[0]?.name ?? null,
|
|
||||||
}).catch(console.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = s.methods.find((m) => m.name === activeRequest.method);
|
|
||||||
if (m == null) {
|
|
||||||
patchModel(activeRequest, {
|
|
||||||
method: s.methods[0]?.name ?? null,
|
|
||||||
}).catch(console.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [activeRequest, services]);
|
|
||||||
|
|
||||||
const activeMethod = useMemo(() => {
|
|
||||||
if (services == null || activeRequest == null) return null;
|
|
||||||
|
|
||||||
const s = services.find((s) => s.name === activeRequest.service);
|
|
||||||
if (s == null) return null;
|
|
||||||
return s.methods.find((m) => m.name === activeRequest.method);
|
|
||||||
}, [activeRequest, services]);
|
|
||||||
|
|
||||||
const methodType:
|
|
||||||
| "unary"
|
|
||||||
| "server_streaming"
|
|
||||||
| "client_streaming"
|
|
||||||
| "streaming"
|
|
||||||
| "no-schema"
|
|
||||||
| "no-method" = useMemo(() => {
|
|
||||||
if (services == null) return "no-schema";
|
|
||||||
if (activeMethod == null) return "no-method";
|
|
||||||
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return "streaming";
|
|
||||||
if (activeMethod.clientStreaming) return "client_streaming";
|
|
||||||
if (activeMethod.serverStreaming) return "server_streaming";
|
|
||||||
return "unary";
|
|
||||||
}, [activeMethod, services]);
|
|
||||||
|
|
||||||
if (activeRequest == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`grpc_layout::${wsId}`}
|
|
||||||
className="p-3 gap-1.5"
|
|
||||||
style={style}
|
|
||||||
layout={workspaceLayout}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<GrpcRequestPane
|
|
||||||
style={style}
|
|
||||||
activeRequest={activeRequest}
|
|
||||||
protoFiles={protoFiles}
|
|
||||||
methodType={methodType}
|
|
||||||
isStreaming={grpc.isStreaming}
|
|
||||||
onGo={grpc.go.mutate}
|
|
||||||
onCommit={grpc.commit.mutate}
|
|
||||||
onCancel={grpc.cancel.mutate}
|
|
||||||
onSend={grpc.send.mutate}
|
|
||||||
services={services ?? null}
|
|
||||||
reflectionError={grpc.reflect.error as string | undefined}
|
|
||||||
reflectionLoading={grpc.reflect.isFetching}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) =>
|
|
||||||
!grpc.go.isPending && (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
"x-theme-responsePane",
|
|
||||||
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
|
|
||||||
"bg-surface rounded-md border border-border-subtle",
|
|
||||||
"shadow relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{grpc.go.error ? (
|
|
||||||
<Banner color="danger" className="m-2">
|
|
||||||
{grpc.go.error}
|
|
||||||
</Banner>
|
|
||||||
) : grpcEvents.length >= 0 ? (
|
|
||||||
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
|
||||||
) : (
|
|
||||||
<HotkeyList hotkeys={["request.send", "sidebar.focus", "url_bar.focus"]} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
||||||
import { useGrpc } from "../hooks/useGrpc";
|
|
||||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GrpcProtoSelectionDialog(props: Props) {
|
|
||||||
const request = useActiveRequest();
|
|
||||||
if (request?.model !== "grpc_request") return null;
|
|
||||||
|
|
||||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
|
||||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
|
||||||
const protoFiles = protoFilesKv.value ?? [];
|
|
||||||
const grpc = useGrpc(request, null, protoFiles);
|
|
||||||
const services = grpc.reflect.data;
|
|
||||||
const serverReflection = protoFiles.length === 0 && services != null;
|
|
||||||
let reflectError = grpc.reflect.error ?? null;
|
|
||||||
const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
|
|
||||||
|
|
||||||
if (reflectionUnimplemented) {
|
|
||||||
reflectError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack className="flex-col-reverse mb-3" space={3}>
|
|
||||||
{/* Buttons on top so they get focus first */}
|
|
||||||
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
variant="border"
|
|
||||||
onClick={async () => {
|
|
||||||
const selected = await open({
|
|
||||||
title: "Select Proto Files",
|
|
||||||
multiple: true,
|
|
||||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
|
||||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
|
||||||
await grpc.reflect.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Proto Files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
const selected = await open({
|
|
||||||
title: "Select Proto Directory",
|
|
||||||
directory: true,
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
|
||||||
await grpc.reflect.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Import Folders
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
isLoading={grpc.reflect.isFetching}
|
|
||||||
disabled={grpc.reflect.isFetching}
|
|
||||||
variant="border"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => grpc.reflect.refetch()}
|
|
||||||
>
|
|
||||||
Refresh Schema
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
<VStack space={5}>
|
|
||||||
{reflectError && (
|
|
||||||
<Banner color="warning">
|
|
||||||
<h1 className="font-bold">
|
|
||||||
Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
|
|
||||||
</h1>
|
|
||||||
<p>{reflectError.trim()}</p>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
{!serverReflection && services != null && services.length > 0 && (
|
|
||||||
<Banner className="flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
Found services{" "}
|
|
||||||
{services?.slice(0, 5).map((s, i) => {
|
|
||||||
return (
|
|
||||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
|
||||||
<InlineCode>{s.name}</InlineCode>
|
|
||||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{services?.length > 5 && pluralizeCount("other", services?.length - 5)}
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
{serverReflection && services != null && services.length > 0 && (
|
|
||||||
<Banner className="flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
Server reflection found services
|
|
||||||
{services?.map((s, i) => {
|
|
||||||
return (
|
|
||||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
|
||||||
<InlineCode>{s.name}</InlineCode>
|
|
||||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
|
|
||||||
files.
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{protoFiles.length > 0 && (
|
|
||||||
<table className="w-full divide-y divide-surface-highlight">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="text-text-subtlest" colSpan={3}>
|
|
||||||
Added File Paths
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{protoFiles.map((f, i) => {
|
|
||||||
const parts = f.split("/");
|
|
||||||
// oxlint-disable-next-line no-array-index-key -- none
|
|
||||||
return (
|
|
||||||
<tr key={f + i} className="group">
|
|
||||||
<td>
|
|
||||||
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
|
|
||||||
</td>
|
|
||||||
<td className="pl-1 font-mono text-sm" title={f}>
|
|
||||||
{parts.length > 3 && ".../"}
|
|
||||||
{parts.slice(-3).join("/")}
|
|
||||||
</td>
|
|
||||||
<td className="w-0 py-0.5">
|
|
||||||
<IconButton
|
|
||||||
title="Remove file"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
icon="trash"
|
|
||||||
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
|
|
||||||
onClick={async () => {
|
|
||||||
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
|
||||||
<Banner>
|
|
||||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
|
||||||
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
|
|
||||||
Server Reflection
|
|
||||||
</Link>{" "}
|
|
||||||
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
|
||||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { GrpcEditor } from "./GrpcEditor";
|
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
|
||||||
import { UrlBar } from "./UrlBar";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style?: CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
activeRequest: GrpcRequest;
|
|
||||||
protoFiles: string[];
|
|
||||||
reflectionError?: string;
|
|
||||||
reflectionLoading?: boolean;
|
|
||||||
methodType:
|
|
||||||
| "unary"
|
|
||||||
| "client_streaming"
|
|
||||||
| "server_streaming"
|
|
||||||
| "streaming"
|
|
||||||
| "no-schema"
|
|
||||||
| "no-method";
|
|
||||||
isStreaming: boolean;
|
|
||||||
onCommit: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSend: (v: { message: string }) => void;
|
|
||||||
onGo: () => void;
|
|
||||||
services: ReflectResponseService[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_MESSAGE = "message";
|
|
||||||
const TAB_METADATA = "metadata";
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_DESCRIPTION = "description";
|
|
||||||
|
|
||||||
export function GrpcRequestPane({
|
|
||||||
style,
|
|
||||||
services,
|
|
||||||
methodType,
|
|
||||||
activeRequest,
|
|
||||||
protoFiles,
|
|
||||||
reflectionError,
|
|
||||||
reflectionLoading,
|
|
||||||
isStreaming,
|
|
||||||
onGo,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
onSend,
|
|
||||||
}: Props) {
|
|
||||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
|
||||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
|
|
||||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
|
||||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
|
||||||
|
|
||||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
|
||||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
|
||||||
|
|
||||||
const handleChangeUrl = useCallback(
|
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeMessage = useCallback(
|
|
||||||
(message: string) => patchModel(activeRequest, { message }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = useMemo(() => {
|
|
||||||
const options =
|
|
||||||
services?.flatMap((s) =>
|
|
||||||
s.methods.map((m) => ({
|
|
||||||
label: `${s.name.split(".").pop() ?? s.name}/${m.name}`,
|
|
||||||
value: `${s.name}/${m.name}`,
|
|
||||||
})),
|
|
||||||
) ?? [];
|
|
||||||
const value = `${activeRequest?.service ?? ""}/${activeRequest?.method ?? ""}`;
|
|
||||||
return { value, options };
|
|
||||||
}, [activeRequest?.method, activeRequest?.service, services]);
|
|
||||||
|
|
||||||
const handleChangeService = useCallback(
|
|
||||||
async (v: string) => {
|
|
||||||
const [serviceName, methodName] = v.split("/", 2);
|
|
||||||
if (serviceName == null || methodName == null) throw new Error("Should never happen");
|
|
||||||
await patchModel(activeRequest, {
|
|
||||||
service: serviceName,
|
|
||||||
method: methodName,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
|
||||||
if (activeRequest == null) return;
|
|
||||||
|
|
||||||
if (activeRequest.service == null || activeRequest.method == null) {
|
|
||||||
alert({
|
|
||||||
id: "grpc-invalid-service-method",
|
|
||||||
title: "Error",
|
|
||||||
body: "Service or method not selected",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onGo();
|
|
||||||
}, [activeRequest, onGo]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
|
||||||
if (activeRequest == null) return;
|
|
||||||
onSend({ message: activeRequest.message });
|
|
||||||
}, [activeRequest, onSend]);
|
|
||||||
|
|
||||||
const tabs: TabItem[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: TAB_MESSAGE, label: "Message" },
|
|
||||||
...metadataTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_DESCRIPTION,
|
|
||||||
label: "Info",
|
|
||||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[activeRequest.description, authTab, metadataTab],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMetadataChange = useCallback(
|
|
||||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
|
||||||
(description: string) => patchModel(activeRequest, { description }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack style={style}>
|
|
||||||
<div
|
|
||||||
ref={urlContainerEl}
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
|
|
||||||
paneWidth === 0 && "opacity-0",
|
|
||||||
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UrlBar
|
|
||||||
key={forceUpdateKey}
|
|
||||||
url={activeRequest.url ?? ""}
|
|
||||||
submitIcon={null}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
placeholder="localhost:50051"
|
|
||||||
onSend={handleConnect}
|
|
||||||
onUrlChange={handleChangeUrl}
|
|
||||||
onCancel={onCancel}
|
|
||||||
isLoading={isStreaming}
|
|
||||||
stateKey={`grpc_url.${activeRequest.id}`}
|
|
||||||
/>
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<RadioDropdown
|
|
||||||
value={select.value}
|
|
||||||
onChange={handleChangeService}
|
|
||||||
items={select.options.map((o) => ({
|
|
||||||
label: o.label,
|
|
||||||
value: o.value,
|
|
||||||
type: "default",
|
|
||||||
shortLabel: o.label,
|
|
||||||
}))}
|
|
||||||
itemsAfter={[
|
|
||||||
{
|
|
||||||
label: "Refresh",
|
|
||||||
type: "default",
|
|
||||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
rightSlot={<Icon size="sm" icon="chevron_down" />}
|
|
||||||
disabled={isStreaming || services == null}
|
|
||||||
className={classNames(
|
|
||||||
"font-mono text-editor min-w-[5rem] !ring-0",
|
|
||||||
paneWidth < 400 && "flex-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{select.options.find((o) => o.value === select.value)?.label ?? "No Schema"}
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
{methodType === "client_streaming" || methodType === "streaming" ? (
|
|
||||||
<>
|
|
||||||
{isStreaming && (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
icon="x"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Commit"
|
|
||||||
onClick={onCommit}
|
|
||||||
icon="check"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title={isStreaming ? "Connect" : "Send"}
|
|
||||||
hotkeyAction="request.send"
|
|
||||||
onClick={isStreaming ? handleSend : handleConnect}
|
|
||||||
icon={isStreaming ? "send_horizontal" : "arrow_up_down"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title={methodType === "unary" ? "Send" : "Connect"}
|
|
||||||
hotkeyAction="request.send"
|
|
||||||
onClick={isStreaming ? onCancel : handleConnect}
|
|
||||||
disabled={methodType === "no-schema" || methodType === "no-method"}
|
|
||||||
icon={
|
|
||||||
isStreaming
|
|
||||||
? "x"
|
|
||||||
: methodType.includes("streaming")
|
|
||||||
? "arrow_up_down"
|
|
||||||
: "send_horizontal"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
<Tabs
|
|
||||||
label="Request"
|
|
||||||
tabs={tabs}
|
|
||||||
tabListClassName="mt-1 !mb-1.5"
|
|
||||||
storageKey="grpc_request_tabs"
|
|
||||||
activeTabKey={activeRequest.id}
|
|
||||||
>
|
|
||||||
<TabContent value="message">
|
|
||||||
<GrpcEditor
|
|
||||||
onChange={handleChangeMessage}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
services={services}
|
|
||||||
reflectionError={reflectionError}
|
|
||||||
reflectionLoading={reflectionLoading}
|
|
||||||
request={activeRequest}
|
|
||||||
protoFiles={protoFiles}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_AUTH}>
|
|
||||||
<HttpAuthenticationEditor model={activeRequest} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_METADATA}>
|
|
||||||
<HeadersEditor
|
|
||||||
inheritedHeaders={inheritedHeaders}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
headers={activeRequest.metadata}
|
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
|
||||||
onChange={handleMetadataChange}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
|
||||||
<PlainInput
|
|
||||||
label="Request Name"
|
|
||||||
hideLabel
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
defaultValue={activeRequest.name}
|
|
||||||
className="font-sans !text-xl !px-0"
|
|
||||||
containerClassName="border-0"
|
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
|
||||||
/>
|
|
||||||
<MarkdownEditor
|
|
||||||
name="request-description"
|
|
||||||
placeholder="Request description"
|
|
||||||
defaultValue={activeRequest.description}
|
|
||||||
stateKey={`description.${activeRequest.id}`}
|
|
||||||
onChange={handleDescriptionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
activeGrpcConnectionAtom,
|
|
||||||
activeGrpcConnections,
|
|
||||||
pinnedGrpcConnectionIdAtom,
|
|
||||||
useGrpcEvents,
|
|
||||||
} from "../hooks/usePinnedGrpcConnection";
|
|
||||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style?: CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
activeRequest: GrpcRequest;
|
|
||||||
methodType:
|
|
||||||
| "unary"
|
|
||||||
| "client_streaming"
|
|
||||||
| "server_streaming"
|
|
||||||
| "streaming"
|
|
||||||
| "no-schema"
|
|
||||||
| "no-method";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|
||||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
|
||||||
const connections = useAtomValue(activeGrpcConnections);
|
|
||||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
|
||||||
const events = useGrpcEvents(activeConnection?.id ?? null);
|
|
||||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
|
||||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
|
||||||
[activeEventIndex, events],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the active message to the first message received if unary
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
useEffect(() => {
|
|
||||||
if (events.length === 0 || activeEvent != null || methodType !== "unary") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === "server_message");
|
|
||||||
if (firstServerMessageIndex !== -1) {
|
|
||||||
setActiveEventIndex(firstServerMessageIndex);
|
|
||||||
}
|
|
||||||
}, [events.length]);
|
|
||||||
|
|
||||||
if (activeConnection == null) {
|
|
||||||
return (
|
|
||||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
|
||||||
<HStack space={2}>
|
|
||||||
<span className="whitespace-nowrap">{events.length} Messages</span>
|
|
||||||
{activeConnection.state !== "closed" && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<RecentGrpcConnectionsDropdown
|
|
||||||
connections={connections}
|
|
||||||
activeConnection={activeConnection}
|
|
||||||
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={style} className="h-full">
|
|
||||||
<ErrorBoundary name="GRPC Events">
|
|
||||||
<EventViewer
|
|
||||||
events={events}
|
|
||||||
getEventKey={(event) => event.id}
|
|
||||||
error={activeConnection.error}
|
|
||||||
header={header}
|
|
||||||
splitLayoutStorageKey="grpc_events"
|
|
||||||
defaultRatio={0.4}
|
|
||||||
renderRow={({ event, isActive, onClick }) => (
|
|
||||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
|
||||||
)}
|
|
||||||
renderDetail={({ event, onClose }) => (
|
|
||||||
<GrpcEventDetail
|
|
||||||
event={event}
|
|
||||||
showLarge={showLarge}
|
|
||||||
showingLarge={showingLarge}
|
|
||||||
setShowLarge={setShowLarge}
|
|
||||||
setShowingLarge={setShowingLarge}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrpcEventRow({
|
|
||||||
event,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
event: GrpcEvent;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const { eventType, status, content, error } = event;
|
|
||||||
const display = getEventDisplay(eventType, status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventViewerRow
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
|
||||||
content={
|
|
||||||
<span className="text-xs">
|
|
||||||
{content.slice(0, 1000)}
|
|
||||||
{error && <span className="text-warning"> ({error})</span>}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrpcEventDetail({
|
|
||||||
event,
|
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
event: GrpcEvent;
|
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
if (event.eventType === "client_message" || event.eventType === "server_message") {
|
|
||||||
const title = `Message ${event.eventType === "client_message" ? "Sent" : "Received"}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<EventDetailHeader
|
|
||||||
title={title}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
copyText={event.content}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
{!showLarge && event.content.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<Editor
|
|
||||||
language="json"
|
|
||||||
defaultValue={event.content ?? ""}
|
|
||||||
wrapLines={false}
|
|
||||||
readOnly={true}
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error or connection_end - show metadata/trailers
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
|
|
||||||
{event.error && (
|
|
||||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
|
||||||
{event.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="py-2 h-full">
|
|
||||||
{Object.keys(event.metadata).length === 0 ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
No {event.eventType === "connection_end" ? "trailers" : "metadata"}
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{Object.entries(event.metadata).map(([key, value]) => (
|
|
||||||
<KeyValueRow key={key} label={key}>
|
|
||||||
{value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventDisplay(
|
|
||||||
eventType: GrpcEvent["eventType"],
|
|
||||||
status: GrpcEvent["status"],
|
|
||||||
): { icon: IconProps["icon"]; color: IconProps["color"]; title: string } {
|
|
||||||
if (eventType === "server_message") {
|
|
||||||
return { icon: "arrow_big_down_dash", color: "info", title: "Server message" };
|
|
||||||
}
|
|
||||||
if (eventType === "client_message") {
|
|
||||||
return { icon: "arrow_big_up_dash", color: "primary", title: "Client message" };
|
|
||||||
}
|
|
||||||
if (eventType === "error" || (status != null && status > 0)) {
|
|
||||||
return { icon: "alert_triangle", color: "danger", title: "Error" };
|
|
||||||
}
|
|
||||||
if (eventType === "connection_end") {
|
|
||||||
return { icon: "check", color: "success", title: "Connection response" };
|
|
||||||
}
|
|
||||||
return { icon: "info", color: undefined, title: "Event" };
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
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 { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import type { InputProps } from "./core/Input";
|
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
|
||||||
import { PairEditorRow } from "./core/PairEditor";
|
|
||||||
import { ensurePairId } from "./core/PairEditor.util";
|
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
forceUpdateKey: string;
|
|
||||||
headers: HttpRequestHeader[];
|
|
||||||
inheritedHeaders?: HttpRequestHeader[];
|
|
||||||
inheritedHeadersLabel?: string;
|
|
||||||
stateKey: string;
|
|
||||||
onChange: (headers: HttpRequestHeader[]) => void;
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HeadersEditor({
|
|
||||||
stateKey,
|
|
||||||
headers,
|
|
||||||
inheritedHeaders,
|
|
||||||
inheritedHeadersLabel = "Inherited",
|
|
||||||
onChange,
|
|
||||||
forceUpdateKey,
|
|
||||||
}: Props) {
|
|
||||||
// Get header names defined at current level (case-insensitive)
|
|
||||||
const currentHeaderNames = new Set(
|
|
||||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
|
||||||
);
|
|
||||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
|
||||||
const validInheritedHeaders =
|
|
||||||
inheritedHeaders?.filter(
|
|
||||||
(pair) =>
|
|
||||||
pair.enabled &&
|
|
||||||
(pair.name || pair.value) &&
|
|
||||||
!currentHeaderNames.has(pair.name.toLowerCase()),
|
|
||||||
) ?? [];
|
|
||||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
hasInheritedHeaders
|
|
||||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
|
||||||
: "@container w-full h-full"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasInheritedHeaders && (
|
|
||||||
<DetailsBanner
|
|
||||||
color="secondary"
|
|
||||||
className="text-sm"
|
|
||||||
summary={
|
|
||||||
<HStack>
|
|
||||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="pb-2">
|
|
||||||
{validInheritedHeaders?.map((pair, i) => (
|
|
||||||
<PairEditorRow
|
|
||||||
key={`${pair.id}.${i}`}
|
|
||||||
index={i}
|
|
||||||
disabled
|
|
||||||
disableDrag
|
|
||||||
className="py-1"
|
|
||||||
pair={ensurePairId(pair)}
|
|
||||||
stateKey={null}
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
nameAutocompleteVariables
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DetailsBanner>
|
|
||||||
)}
|
|
||||||
<PairOrBulkEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
nameAutocomplete={nameAutocomplete}
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
nameAutocompleteVariables
|
|
||||||
namePlaceholder="Header-Name"
|
|
||||||
nameValidate={validateHttpHeader}
|
|
||||||
onChange={onChange}
|
|
||||||
pairs={headers}
|
|
||||||
preferenceName="headers"
|
|
||||||
stateKey={stateKey}
|
|
||||||
valueType={valueType}
|
|
||||||
valueAutocomplete={valueAutocomplete}
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_MATCH = 3;
|
|
||||||
|
|
||||||
const headerOptionsMap: Record<string, string[]> = {
|
|
||||||
"content-type": mimeTypes,
|
|
||||||
accept: ["*/*", ...mimeTypes],
|
|
||||||
"accept-encoding": encodings,
|
|
||||||
connection: connections,
|
|
||||||
"accept-charset": charsets,
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueType = (pair: Pair): InputProps["type"] => {
|
|
||||||
const name = pair.name.toLowerCase().trim();
|
|
||||||
if (
|
|
||||||
name.includes("authorization") ||
|
|
||||||
name.includes("api-key") ||
|
|
||||||
name.includes("access-token") ||
|
|
||||||
name.includes("auth") ||
|
|
||||||
name.includes("secret") ||
|
|
||||||
name.includes("token") ||
|
|
||||||
name === "cookie" ||
|
|
||||||
name === "set-cookie"
|
|
||||||
) {
|
|
||||||
return "password";
|
|
||||||
}
|
|
||||||
return "text";
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
|
||||||
const name = headerName.toLowerCase().trim();
|
|
||||||
const options: GenericCompletionOption[] =
|
|
||||||
headerOptionsMap[name]?.map((o) => ({
|
|
||||||
label: o,
|
|
||||||
type: "constant",
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
})) ?? [];
|
|
||||||
return { minMatch: MIN_MATCH, options };
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
|
||||||
minMatch: MIN_MATCH,
|
|
||||||
options: headerNames.map((t) =>
|
|
||||||
typeof t === "string"
|
|
||||||
? {
|
|
||||||
label: t,
|
|
||||||
type: "constant",
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...t,
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateHttpHeader = (v: string) => {
|
|
||||||
if (v === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template strings are not allowed so we replace them with a valid example string
|
|
||||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
|
||||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
|
||||||
};
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
|
||||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
|
||||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
|
||||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Input, type InputProps } from "./core/Input";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
import { DynamicForm } from "./DynamicForm";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpAuthenticationEditor({ model }: Props) {
|
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
|
||||||
const authConfig = useHttpAuthenticationConfig(
|
|
||||||
model.authenticationType,
|
|
||||||
model.authentication,
|
|
||||||
model,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
|
||||||
[model],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (model.authenticationType === "none") {
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.authenticationType != null && authConfig.data == null) {
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<p>
|
|
||||||
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
|
||||||
</p>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inheritedAuth == null) {
|
|
||||||
if (model.model === "workspace" || model.model === "folder") {
|
|
||||||
return (
|
|
||||||
<EmptyStateText className="flex-col gap-1">
|
|
||||||
<p>
|
|
||||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
|
||||||
</p>
|
|
||||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inheritedAuth.authenticationType === "none") {
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasAuthInherited = inheritedAuth?.id !== model.id;
|
|
||||||
if (wasAuthInherited) {
|
|
||||||
const name = resolvedModelName(inheritedAuth);
|
|
||||||
const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<p>
|
|
||||||
Inherited from{" "}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="underline hover:text-text"
|
|
||||||
onClick={() => {
|
|
||||||
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
|
||||||
else openWorkspaceSettings("auth");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cta}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
|
|
||||||
<div>
|
|
||||||
<HStack space={2} alignItems="start">
|
|
||||||
<SegmentedControl
|
|
||||||
label="Enabled"
|
|
||||||
hideLabel
|
|
||||||
name="enabled"
|
|
||||||
value={
|
|
||||||
model.authentication.disabled === false || model.authentication.disabled == null
|
|
||||||
? "__TRUE__"
|
|
||||||
: model.authentication.disabled === true
|
|
||||||
? "__FALSE__"
|
|
||||||
: "__DYNAMIC__"
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ label: "Enabled", value: "__TRUE__" },
|
|
||||||
{ label: "Disabled", value: "__FALSE__" },
|
|
||||||
{ label: "Enabled when...", value: "__DYNAMIC__" },
|
|
||||||
]}
|
|
||||||
onChange={async (enabled) => {
|
|
||||||
let disabled: boolean | string;
|
|
||||||
if (enabled === "__TRUE__") {
|
|
||||||
disabled = false;
|
|
||||||
} else if (enabled === "__FALSE__") {
|
|
||||||
disabled = true;
|
|
||||||
} else {
|
|
||||||
disabled = "";
|
|
||||||
}
|
|
||||||
await handleChange({ ...model.authentication, disabled });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
|
|
||||||
<Dropdown
|
|
||||||
items={authConfig.data.actions.map(
|
|
||||||
(a): DropdownItem => ({
|
|
||||||
label: a.label,
|
|
||||||
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
|
|
||||||
onSelect: () => a.call(model),
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
title="Authentication Actions"
|
|
||||||
icon="settings"
|
|
||||||
size="xs"
|
|
||||||
className="!text-secondary"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
{typeof model.authentication.disabled === "string" && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<AuthenticationDisabledInput
|
|
||||||
className="w-full"
|
|
||||||
stateKey={`auth.${model.id}.dynamic`}
|
|
||||||
value={model.authentication.disabled}
|
|
||||||
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DynamicForm
|
|
||||||
disabled={model.authentication.disabled === true}
|
|
||||||
autocompleteVariables
|
|
||||||
autocompleteFunctions
|
|
||||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
|
||||||
inputs={authConfig.data?.args ?? []}
|
|
||||||
data={model.authentication}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthenticationDisabledInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
stateKey,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: InputProps["onChange"];
|
|
||||||
stateKey: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const rendered = useRenderTemplate({
|
|
||||||
template: value,
|
|
||||||
enabled: true,
|
|
||||||
purpose: "preview",
|
|
||||||
refreshKey: value,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
label="Dynamic Disabled"
|
|
||||||
hideLabel
|
|
||||||
defaultValue={value}
|
|
||||||
placeholder="Enabled when this renders a non-empty value"
|
|
||||||
rightSlot={
|
|
||||||
<div className="px-1 flex items-center">
|
|
||||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
|
||||||
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
onChange={onChange}
|
|
||||||
stateKey={stateKey}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import type { SlotProps } from "@yaakapp-internal/ui";
|
|
||||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useCurrentGraphQLSchema } from "../hooks/useIntrospectGraphQL";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
|
||||||
import { GraphQLDocsExplorer } from "./graphql/GraphQLDocsExplorer";
|
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphql/graphqlAtoms";
|
|
||||||
import { HttpRequestPane } from "./HttpRequestPane";
|
|
||||||
import { HttpResponsePane } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
activeRequest: HttpRequest;
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpRequestLayout({ activeRequest, style }: Props) {
|
|
||||||
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
|
|
||||||
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
|
|
||||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const wsId = activeWorkspace?.id ?? "n/a";
|
|
||||||
|
|
||||||
const requestResponseSplit = ({ style }: Pick<SlotProps, "style">) => (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`http_layout::${wsId}`}
|
|
||||||
className="p-3 gap-1.5"
|
|
||||||
style={style}
|
|
||||||
layout={workspaceLayout}
|
|
||||||
firstSlot={({ orientation, style }) => (
|
|
||||||
<HttpRequestPane
|
|
||||||
style={style}
|
|
||||||
activeRequest={activeRequest}
|
|
||||||
fullHeight={orientation === "horizontal"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<HttpResponsePane activeRequestId={activeRequest.id} style={style} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
activeRequest.bodyType === "graphql" &&
|
|
||||||
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
|
|
||||||
graphQLSchema != null
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`graphql_layout::${wsId}`}
|
|
||||||
defaultRatio={1 / 3}
|
|
||||||
firstSlot={requestResponseSplit}
|
|
||||||
secondSlot={({ style, orientation }) => (
|
|
||||||
<GraphQLDocsExplorer
|
|
||||||
requestId={activeRequest.id}
|
|
||||||
schema={graphQLSchema}
|
|
||||||
className={classNames(orientation === "horizontal" && "!ml-0")}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestResponseSplit({ style });
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
|
||||||
import { generateId } from "../lib/generateId";
|
|
||||||
import {
|
|
||||||
BODY_TYPE_BINARY,
|
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
|
||||||
BODY_TYPE_FORM_URLENCODED,
|
|
||||||
BODY_TYPE_GRAPHQL,
|
|
||||||
BODY_TYPE_JSON,
|
|
||||||
BODY_TYPE_NONE,
|
|
||||||
BODY_TYPE_OTHER,
|
|
||||||
BODY_TYPE_XML,
|
|
||||||
getContentTypeFromHeaders,
|
|
||||||
} from "../lib/model_util";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
|
||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
|
||||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|
||||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
|
||||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
|
||||||
import { UrlBar } from "./UrlBar";
|
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
|
||||||
|
|
||||||
const GraphQLEditor = lazy(() =>
|
|
||||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style: CSSProperties;
|
|
||||||
fullHeight: boolean;
|
|
||||||
className?: string;
|
|
||||||
activeRequest: HttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_BODY = "body";
|
|
||||||
const TAB_PARAMS = "params";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_DESCRIPTION = "description";
|
|
||||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
|
||||||
const requests = get(allRequestsAtom);
|
|
||||||
return requests
|
|
||||||
.filter((r) => r.id !== activeRequestId)
|
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
|
||||||
const activeRequestId = activeRequest.id;
|
|
||||||
const tabsRef = useRef<TabsRef>(null);
|
|
||||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
|
||||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
|
||||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
|
||||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
|
||||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
|
||||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
|
||||||
|
|
||||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
|
||||||
useRequestEditorEvent(
|
|
||||||
"request_pane.focus_tab",
|
|
||||||
() => {
|
|
||||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContentTypeChange = useCallback(
|
|
||||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
|
|
||||||
if (activeRequest == null) {
|
|
||||||
console.error("Failed to get active request to update", activeRequest);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
|
|
||||||
|
|
||||||
if (contentType != null) {
|
|
||||||
headers.push({
|
|
||||||
name: "Content-Type",
|
|
||||||
value: contentType,
|
|
||||||
enabled: true,
|
|
||||||
id: generateId(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await patchModel(activeRequest, { ...patch, headers });
|
|
||||||
|
|
||||||
// Force update header editor so any changed headers are reflected
|
|
||||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
|
||||||
},
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
|
||||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
|
||||||
(m) => m[1] ?? "",
|
|
||||||
);
|
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
|
||||||
for (const name of placeholderNames) {
|
|
||||||
const item = items.find((p) => p.name === name);
|
|
||||||
if (item) {
|
|
||||||
item.readOnlyName = true;
|
|
||||||
} else {
|
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
|
||||||
|
|
||||||
let numParams = 0;
|
|
||||||
if (
|
|
||||||
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
|
|
||||||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
|
|
||||||
) {
|
|
||||||
numParams = Array.isArray(activeRequest.body?.form)
|
|
||||||
? activeRequest.body.form.filter((p) => p.name).length
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: TAB_BODY,
|
|
||||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
|
||||||
options: {
|
|
||||||
value: activeRequest.bodyType,
|
|
||||||
items: [
|
|
||||||
{ type: "separator", label: "Form Data" },
|
|
||||||
{ label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
|
|
||||||
{ label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
|
|
||||||
{ type: "separator", label: "Text Content" },
|
|
||||||
{ label: "GraphQL", value: BODY_TYPE_GRAPHQL },
|
|
||||||
{ label: "JSON", value: BODY_TYPE_JSON },
|
|
||||||
{ label: "XML", value: BODY_TYPE_XML },
|
|
||||||
{
|
|
||||||
label: "Other",
|
|
||||||
value: BODY_TYPE_OTHER,
|
|
||||||
shortLabel: nameOfContentTypeOr(contentType, "Other"),
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Other" },
|
|
||||||
{ label: "Binary File", value: BODY_TYPE_BINARY },
|
|
||||||
{ label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
|
|
||||||
],
|
|
||||||
onChange: async (bodyType) => {
|
|
||||||
if (bodyType === activeRequest.bodyType) return;
|
|
||||||
|
|
||||||
const showMethodToast = (newMethod: string) => {
|
|
||||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
|
||||||
showToast({
|
|
||||||
id: "switched-method",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Request method switched to <InlineCode>POST</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const patch: Partial<HttpRequest> = { bodyType };
|
|
||||||
let newContentType: string | null | undefined;
|
|
||||||
if (bodyType === BODY_TYPE_NONE) {
|
|
||||||
newContentType = null;
|
|
||||||
} else if (
|
|
||||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
|
||||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
|
||||||
bodyType === BODY_TYPE_JSON ||
|
|
||||||
bodyType === BODY_TYPE_OTHER ||
|
|
||||||
bodyType === BODY_TYPE_XML
|
|
||||||
) {
|
|
||||||
const isDefaultishRequest =
|
|
||||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
|
||||||
activeRequest.method.toLowerCase() === "get";
|
|
||||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
|
||||||
if (isDefaultishRequest || requiresPost) {
|
|
||||||
patch.method = "POST";
|
|
||||||
showMethodToast(patch.method);
|
|
||||||
}
|
|
||||||
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
|
|
||||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
|
||||||
patch.method = "POST";
|
|
||||||
newContentType = "application/json";
|
|
||||||
showMethodToast(patch.method);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newContentType !== undefined) {
|
|
||||||
await handleContentTypeChange(newContentType, patch);
|
|
||||||
} else {
|
|
||||||
await patchModel(activeRequest, patch);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_PARAMS,
|
|
||||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
|
||||||
label: "Params",
|
|
||||||
},
|
|
||||||
...headersTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_DESCRIPTION,
|
|
||||||
label: "Info",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
activeRequest,
|
|
||||||
authTab,
|
|
||||||
contentType,
|
|
||||||
handleContentTypeChange,
|
|
||||||
headersTab,
|
|
||||||
numParams,
|
|
||||||
urlParameterPairs.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
|
||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
|
||||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
|
||||||
const { mutate: importCurl } = useImportCurl();
|
|
||||||
|
|
||||||
const handleBodyChange = useCallback(
|
|
||||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
|
||||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
|
||||||
async (e: ClipboardEvent, text: string) => {
|
|
||||||
if (text.startsWith("curl ")) {
|
|
||||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
|
||||||
} else {
|
|
||||||
const patch = prepareImportQuerystring(text);
|
|
||||||
if (patch != null) {
|
|
||||||
e.preventDefault(); // Prevent input onChange
|
|
||||||
|
|
||||||
await patchModel(activeRequest, patch);
|
|
||||||
await setActiveTab({
|
|
||||||
storageKey: TABS_STORAGE_KEY,
|
|
||||||
activeTabKey: activeRequestId,
|
|
||||||
value: TAB_PARAMS,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for request to update, then refresh the UI
|
|
||||||
// TODO: Somehow make this deterministic
|
|
||||||
setTimeout(() => {
|
|
||||||
forceUrlRefresh();
|
|
||||||
forceParamsRefresh();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
|
||||||
);
|
|
||||||
const handleSend = useCallback(
|
|
||||||
() => sendRequest(activeRequest.id ?? null),
|
|
||||||
[activeRequest.id, sendRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
|
||||||
>
|
|
||||||
{activeRequest && (
|
|
||||||
<>
|
|
||||||
<UrlBar
|
|
||||||
stateKey={`url.${activeRequest.id}`}
|
|
||||||
key={forceUpdateKey + urlKey}
|
|
||||||
url={activeRequest.url}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
onPasteOverwrite={handlePaste}
|
|
||||||
autocomplete={autocomplete}
|
|
||||||
onSend={handleSend}
|
|
||||||
onCancel={cancelResponse}
|
|
||||||
onUrlChange={handleUrlChange}
|
|
||||||
leftSlot={
|
|
||||||
<div className="py-0.5">
|
|
||||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
|
||||||
/>
|
|
||||||
<Tabs
|
|
||||||
ref={tabsRef}
|
|
||||||
label="Request"
|
|
||||||
tabs={tabs}
|
|
||||||
tabListClassName="mt-1 -mb-1.5"
|
|
||||||
storageKey={TABS_STORAGE_KEY}
|
|
||||||
activeTabKey={activeRequestId}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_AUTH}>
|
|
||||||
<HttpAuthenticationEditor model={activeRequest} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_HEADERS}>
|
|
||||||
<HeadersEditor
|
|
||||||
inheritedHeaders={inheritedHeaders}
|
|
||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
|
||||||
headers={activeRequest.headers}
|
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PARAMS}>
|
|
||||||
<UrlParametersEditor
|
|
||||||
stateKey={`params.${activeRequest.id}`}
|
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
|
||||||
pairs={urlParameterPairs}
|
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_BODY}>
|
|
||||||
<ConfirmLargeRequestBody request={activeRequest}>
|
|
||||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
|
||||||
<JsonBodyEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
request={activeRequest}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
|
||||||
<Editor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
|
||||||
language="xml"
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`xml.${activeRequest.id}`}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
|
||||||
<Suspense>
|
|
||||||
<GraphQLEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
baseRequest={activeRequest}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
|
|
||||||
<FormUrlencodedEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
|
|
||||||
<FormMultipartEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
|
||||||
<BinaryFileEditor
|
|
||||||
requestId={activeRequest.id}
|
|
||||||
contentType={contentType}
|
|
||||||
body={activeRequest.body}
|
|
||||||
onChange={(body) => patchModel(activeRequest, { body })}
|
|
||||||
onChangeContentType={handleContentTypeChange}
|
|
||||||
/>
|
|
||||||
) : typeof activeRequest.bodyType === "string" ? (
|
|
||||||
<Editor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
language={languageFromContentType(contentType)}
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`other.${activeRequest.id}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyStateText>No Body</EmptyStateText>
|
|
||||||
)}
|
|
||||||
</ConfirmLargeRequestBody>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
|
||||||
<PlainInput
|
|
||||||
label="Request Name"
|
|
||||||
hideLabel
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
defaultValue={activeRequest.name}
|
|
||||||
className="font-sans !text-xl !px-0"
|
|
||||||
containerClassName="border-0"
|
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
|
||||||
/>
|
|
||||||
<MarkdownEditor
|
|
||||||
name="request-description"
|
|
||||||
placeholder="Request description"
|
|
||||||
defaultValue={activeRequest.description}
|
|
||||||
stateKey={`description.${activeRequest.id}`}
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
onChange={(description) => patchModel(activeRequest, { description })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
|
||||||
const language = languageFromContentType(contentType);
|
|
||||||
if (language === "markdown") {
|
|
||||||
return "Markdown";
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ComponentType, CSSProperties } from "react";
|
|
||||||
import { lazy, Suspense, useMemo } from "react";
|
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
|
||||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
|
||||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
|
||||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
|
||||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
|
||||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { Tooltip } from "./core/Tooltip";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
|
||||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
|
||||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
|
||||||
import { ResponseCookies } from "./ResponseCookies";
|
|
||||||
import { ResponseHeaders } from "./ResponseHeaders";
|
|
||||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
|
||||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
|
||||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
|
||||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
|
||||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
|
||||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style?: CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
activeRequestId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_BODY = "body";
|
|
||||||
const TAB_REQUEST = "request";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_COOKIES = "cookies";
|
|
||||||
const TAB_TIMELINE = "timeline";
|
|
||||||
|
|
||||||
export type TimelineViewMode = "timeline" | "text";
|
|
||||||
|
|
||||||
interface RedirectDropWarning {
|
|
||||||
droppedBodyCount: number;
|
|
||||||
droppedHeaders: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
|
||||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
|
||||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
|
||||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
|
||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
|
||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
|
||||||
const redirectDropWarning = useMemo(
|
|
||||||
() => getRedirectDropWarning(responseEvents.data),
|
|
||||||
[responseEvents.data],
|
|
||||||
);
|
|
||||||
const shouldShowRedirectDropWarning =
|
|
||||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
|
||||||
|
|
||||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: TAB_BODY,
|
|
||||||
label: "Response",
|
|
||||||
options: {
|
|
||||||
value: viewMode,
|
|
||||||
onChange: setViewMode,
|
|
||||||
items: [
|
|
||||||
{ label: "Response", value: "pretty" },
|
|
||||||
...(mimeType?.startsWith("image")
|
|
||||||
? []
|
|
||||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_REQUEST,
|
|
||||||
label: "Request",
|
|
||||||
rightSlot:
|
|
||||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_HEADERS,
|
|
||||||
label: "Headers",
|
|
||||||
rightSlot: (
|
|
||||||
<CountBadge
|
|
||||||
count={activeResponse?.requestHeaders.length ?? 0}
|
|
||||||
count2={activeResponse?.headers.length ?? 0}
|
|
||||||
showZero
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_COOKIES,
|
|
||||||
label: "Cookies",
|
|
||||||
rightSlot:
|
|
||||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
|
||||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_TIMELINE,
|
|
||||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
|
||||||
options: {
|
|
||||||
value: timelineViewMode,
|
|
||||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
|
||||||
items: [
|
|
||||||
{ label: "Timeline", value: "timeline" },
|
|
||||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
activeResponse?.headers,
|
|
||||||
activeResponse?.requestContentLength,
|
|
||||||
activeResponse?.requestHeaders.length,
|
|
||||||
cookieCounts.sent,
|
|
||||||
cookieCounts.received,
|
|
||||||
mimeType,
|
|
||||||
responseEvents.data?.length,
|
|
||||||
setViewMode,
|
|
||||||
viewMode,
|
|
||||||
timelineViewMode,
|
|
||||||
setTimelineViewMode,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"x-theme-responsePane",
|
|
||||||
"max-h-full h-full",
|
|
||||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
|
||||||
"relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeResponse == null ? (
|
|
||||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
|
||||||
<HStack
|
|
||||||
className={classNames(
|
|
||||||
"text-text-subtle w-full flex-shrink-0",
|
|
||||||
// Remove a bit of space because the tabs have lots too
|
|
||||||
"-mb-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeResponse && (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
|
||||||
"cursor-default select-none",
|
|
||||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HStack space={2} className="w-full flex-shrink-0">
|
|
||||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
|
||||||
<HttpStatusTag showReason response={activeResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<HttpResponseDurationTag response={activeResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<SizeTag
|
|
||||||
contentLength={activeResponse.contentLength ?? 0}
|
|
||||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
{shouldShowRedirectDropWarning ? (
|
|
||||||
<Tooltip
|
|
||||||
tabIndex={0}
|
|
||||||
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
|
||||||
content={
|
|
||||||
<VStack alignItems="start" space={1} className="text-xs">
|
|
||||||
<span className="font-medium text-warning">
|
|
||||||
Redirect changed this request
|
|
||||||
</span>
|
|
||||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
|
||||||
<span>
|
|
||||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
|
||||||
{redirectDropWarning.droppedBodyCount === 1
|
|
||||||
? "redirect hop"
|
|
||||||
: "redirect hops"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
|
||||||
<span>
|
|
||||||
Headers dropped:{" "}
|
|
||||||
<span className="font-mono">
|
|
||||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-text-subtle">See Timeline for details.</span>
|
|
||||||
</VStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="inline-flex min-w-0">
|
|
||||||
<PillButton
|
|
||||||
color="warning"
|
|
||||||
className="font-sans text-sm !flex-shrink max-w-full"
|
|
||||||
innerClassName="flex items-center"
|
|
||||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{getRedirectWarningLabel(redirectDropWarning)}
|
|
||||||
</span>
|
|
||||||
</PillButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<div className="justify-self-end flex-shrink-0">
|
|
||||||
<RecentHttpResponsesDropdown
|
|
||||||
responses={responses}
|
|
||||||
activeResponse={activeResponse}
|
|
||||||
onPinnedResponseId={setPinnedResponseId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<div className="overflow-hidden flex flex-col min-h-0">
|
|
||||||
{activeResponse?.error && (
|
|
||||||
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
|
|
||||||
{activeResponse.error}
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
|
||||||
<Tabs
|
|
||||||
tabs={tabs}
|
|
||||||
label="Response"
|
|
||||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
|
||||||
tabListClassName="mt-0.5 -mb-1.5"
|
|
||||||
storageKey="http_response_tabs"
|
|
||||||
activeTabKey={activeRequestId}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_BODY}>
|
|
||||||
<ErrorBoundary name="Http Response Viewer">
|
|
||||||
<Suspense>
|
|
||||||
<ConfirmLargeResponse response={activeResponse}>
|
|
||||||
{activeResponse.state === "initialized" ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
<VStack space={3}>
|
|
||||||
<HStack space={3}>
|
|
||||||
<LoadingIcon className="text-text-subtlest" />
|
|
||||||
Sending Request
|
|
||||||
</HStack>
|
|
||||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</EmptyStateText>
|
|
||||||
) : activeResponse.state === "closed" &&
|
|
||||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
|
||||||
<EmptyStateText>Empty</EmptyStateText>
|
|
||||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
|
||||||
<EventStreamViewer response={activeResponse} />
|
|
||||||
) : mimeType?.match(/^image\/svg/) ? (
|
|
||||||
<HttpSvgViewer response={activeResponse} />
|
|
||||||
) : mimeType?.match(/^image/i) ? (
|
|
||||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
|
||||||
) : mimeType?.match(/^audio/i) ? (
|
|
||||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
|
||||||
) : mimeType?.match(/^video/i) ? (
|
|
||||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
|
||||||
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
|
|
||||||
<HttpMultipartViewer response={activeResponse} />
|
|
||||||
) : mimeType?.match(/pdf/i) ? (
|
|
||||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
|
||||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
|
|
||||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
|
||||||
) : (
|
|
||||||
<HTMLOrTextViewer
|
|
||||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
|
||||||
response={activeResponse}
|
|
||||||
pretty={viewMode === "pretty"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ConfirmLargeResponse>
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_REQUEST}>
|
|
||||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
|
||||||
<RequestBodyViewer response={activeResponse} />
|
|
||||||
</ConfirmLargeResponseRequest>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_HEADERS}>
|
|
||||||
<ResponseHeaders response={activeResponse} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_COOKIES}>
|
|
||||||
<ResponseCookies response={activeResponse} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_TIMELINE}>
|
|
||||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRedirectDropWarning(
|
|
||||||
events: HttpResponseEvent[] | undefined,
|
|
||||||
): RedirectDropWarning | null {
|
|
||||||
if (events == null || events.length === 0) return null;
|
|
||||||
|
|
||||||
let droppedBodyCount = 0;
|
|
||||||
const droppedHeaders = new Set<string>();
|
|
||||||
for (const e of events) {
|
|
||||||
const event = e.event;
|
|
||||||
if (event.type !== "redirect") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.dropped_body) {
|
|
||||||
droppedBodyCount += 1;
|
|
||||||
}
|
|
||||||
for (const headerName of event.dropped_headers ?? []) {
|
|
||||||
pushHeaderName(droppedHeaders, headerName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
droppedBodyCount,
|
|
||||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
|
||||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
|
||||||
if (existing == null) {
|
|
||||||
headers.add(headerName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
|
||||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
|
||||||
return "Dropped body and headers";
|
|
||||||
}
|
|
||||||
if (warning.droppedBodyCount > 0) {
|
|
||||||
return "Dropped body";
|
|
||||||
}
|
|
||||||
return "Dropped headers";
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnsureCompleteResponse({
|
|
||||||
response,
|
|
||||||
Component,
|
|
||||||
}: {
|
|
||||||
response: HttpResponse;
|
|
||||||
Component: ComponentType<{ bodyPath: string }>;
|
|
||||||
}) {
|
|
||||||
if (response.bodyPath === null) {
|
|
||||||
return <div>Empty response body</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until the response has been fully-downloaded
|
|
||||||
if (response.state !== "closed") {
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<LoadingIcon />
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component bodyPath={response.bodyPath} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
|
||||||
const body = useResponseBodyText({ response, filter: null });
|
|
||||||
|
|
||||||
if (!body.data) return null;
|
|
||||||
|
|
||||||
return <SvgViewer text={body.data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
|
||||||
const body = useResponseBodyText({ response, filter: null });
|
|
||||||
|
|
||||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
|
|
||||||
const body = useResponseBodyBytes({ response });
|
|
||||||
|
|
||||||
if (body.data == null) return null;
|
|
||||||
|
|
||||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
|
||||||
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
|
|
||||||
|
|
||||||
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
import type {
|
|
||||||
HttpResponse,
|
|
||||||
HttpResponseEvent,
|
|
||||||
HttpResponseEventData,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
|
||||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
|
||||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
viewMode: TimelineViewMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
|
||||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inner({ response, viewMode }: Props) {
|
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
|
||||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
|
||||||
|
|
||||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
|
||||||
const plainText = useMemo(() => {
|
|
||||||
if (!events || events.length === 0) return "";
|
|
||||||
return events.map((event) => formatEventText(event.event, true)).join("\n");
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
// Plain text view - show all events as text in an editor
|
|
||||||
if (viewMode === "text") {
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
|
||||||
} else if (error) {
|
|
||||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
|
||||||
} else if (!events || events.length === 0) {
|
|
||||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventViewer
|
|
||||||
events={events ?? []}
|
|
||||||
getEventKey={(event) => event.id}
|
|
||||||
error={error ? String(error) : null}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage="Loading events..."
|
|
||||||
emptyMessage="No events recorded"
|
|
||||||
splitLayoutStorageKey="http_response_events"
|
|
||||||
defaultRatio={0.25}
|
|
||||||
renderRow={({ event, isActive, onClick }) => {
|
|
||||||
const display = getEventDisplay(event.event);
|
|
||||||
return (
|
|
||||||
<EventViewerRow
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
|
||||||
content={display.summary}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderDetail={({ event, onClose }) => (
|
|
||||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventDetails({
|
|
||||||
event,
|
|
||||||
showRaw,
|
|
||||||
setShowRaw,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
event: HttpResponseEvent;
|
|
||||||
showRaw: boolean;
|
|
||||||
setShowRaw: (v: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { label } = getEventDisplay(event.event);
|
|
||||||
const e = event.event;
|
|
||||||
|
|
||||||
const actions: EventDetailAction[] = [
|
|
||||||
{
|
|
||||||
key: "toggle-raw",
|
|
||||||
label: showRaw ? "Formatted" : "Text",
|
|
||||||
onClick: () => setShowRaw(!showRaw),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Determine the title based on event type
|
|
||||||
const title = (() => {
|
|
||||||
switch (e.type) {
|
|
||||||
case "header_up":
|
|
||||||
return "Header Sent";
|
|
||||||
case "header_down":
|
|
||||||
return "Header Received";
|
|
||||||
case "send_url":
|
|
||||||
return "Request";
|
|
||||||
case "receive_url":
|
|
||||||
return "Response";
|
|
||||||
case "redirect":
|
|
||||||
return "Redirect";
|
|
||||||
case "setting":
|
|
||||||
return "Apply Setting";
|
|
||||||
case "chunk_sent":
|
|
||||||
return "Data Sent";
|
|
||||||
case "chunk_received":
|
|
||||||
return "Data Received";
|
|
||||||
case "dns_resolved":
|
|
||||||
return e.overridden ? "DNS Override" : "DNS Resolution";
|
|
||||||
default:
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Render content based on view mode and event type
|
|
||||||
const renderContent = () => {
|
|
||||||
// Raw view - show plaintext representation (without prefix)
|
|
||||||
if (showRaw) {
|
|
||||||
const rawText = formatEventText(event.event, false);
|
|
||||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headers - show name and value
|
|
||||||
if (e.type === "header_up" || e.type === "header_down") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request URL - show all URL parts separately
|
|
||||||
if (e.type === "send_url") {
|
|
||||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
|
|
||||||
const isDefaultPort =
|
|
||||||
(e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
|
|
||||||
const portStr = isDefaultPort ? "" : `:${e.port}`;
|
|
||||||
const query = e.query ? `?${e.query}` : "";
|
|
||||||
const fragment = e.fragment ? `#${e.fragment}` : "";
|
|
||||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
|
||||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
|
||||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
|
||||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
|
||||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
|
||||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
|
||||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
|
||||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response status - show version and status separately
|
|
||||||
if (e.type === "receive_url") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Status">
|
|
||||||
<HttpStatusTagRaw status={e.status} />
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect - show status, URL, and behavior
|
|
||||||
if (e.type === "redirect") {
|
|
||||||
const droppedHeaders = e.dropped_headers ?? [];
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Status">
|
|
||||||
<HttpStatusTagRaw status={e.status} />
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Behavior">
|
|
||||||
{e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Headers Dropped">
|
|
||||||
{droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings - show as key/value
|
|
||||||
if (e.type === "setting") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunks - show formatted bytes
|
|
||||||
if (e.type === "chunk_sent" || e.type === "chunk_received") {
|
|
||||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS Resolution - show hostname, addresses, and timing
|
|
||||||
if (e.type === "dns_resolved") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Duration">
|
|
||||||
{e.overridden ? (
|
|
||||||
<span className="text-text-subtlest">--</span>
|
|
||||||
) : (
|
|
||||||
`${String(e.duration)}ms`
|
|
||||||
)}
|
|
||||||
</KeyValueRow>
|
|
||||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default - use summary
|
|
||||||
const { summary } = getEventDisplay(event.event);
|
|
||||||
return <div className="font-mono text-editor">{summary}</div>;
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 h-full">
|
|
||||||
<EventDetailHeader
|
|
||||||
title={title}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
actions={actions}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
|
|
||||||
|
|
||||||
/** Get the prefix and text for an event */
|
|
||||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
|
||||||
switch (event.type) {
|
|
||||||
case "send_url":
|
|
||||||
return {
|
|
||||||
prefix: ">",
|
|
||||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
|
||||||
};
|
|
||||||
case "receive_url":
|
|
||||||
return { prefix: "<", text: `${event.version} ${event.status}` };
|
|
||||||
case "header_up":
|
|
||||||
return { prefix: ">", text: `${event.name}: ${event.value}` };
|
|
||||||
case "header_down":
|
|
||||||
return { prefix: "<", text: `${event.name}: ${event.value}` };
|
|
||||||
case "redirect": {
|
|
||||||
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
|
|
||||||
const droppedHeaders = event.dropped_headers ?? [];
|
|
||||||
const dropped = [
|
|
||||||
event.dropped_body ? "body dropped" : null,
|
|
||||||
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "setting":
|
|
||||||
return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
|
|
||||||
case "info":
|
|
||||||
return { prefix: "*", text: event.message };
|
|
||||||
case "chunk_sent":
|
|
||||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
|
|
||||||
case "chunk_received":
|
|
||||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
|
|
||||||
case "dns_resolved":
|
|
||||||
if (event.overridden) {
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return { prefix: "*", text: "[unknown event]" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
|
||||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
|
||||||
const { prefix, text } = getEventTextParts(event);
|
|
||||||
return includePrefix ? `${prefix} ${text}` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventDisplay = {
|
|
||||||
icon: IconProps["icon"];
|
|
||||||
color: IconProps["color"];
|
|
||||||
label: string;
|
|
||||||
summary: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|
||||||
switch (event.type) {
|
|
||||||
case "setting":
|
|
||||||
return {
|
|
||||||
icon: "settings",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Setting",
|
|
||||||
summary: `${event.name} = ${event.value}`,
|
|
||||||
};
|
|
||||||
case "info":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Info",
|
|
||||||
summary: event.message,
|
|
||||||
};
|
|
||||||
case "redirect": {
|
|
||||||
const droppedHeaders = event.dropped_headers ?? [];
|
|
||||||
const dropped = [
|
|
||||||
event.dropped_body ? "drop body" : null,
|
|
||||||
droppedHeaders.length > 0
|
|
||||||
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_right_dash",
|
|
||||||
color: "success",
|
|
||||||
label: "Redirect",
|
|
||||||
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ""}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "send_url":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_up_dash",
|
|
||||||
color: "primary",
|
|
||||||
label: "Request",
|
|
||||||
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
|
||||||
};
|
|
||||||
case "receive_url":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_down_dash",
|
|
||||||
color: "info",
|
|
||||||
label: "Response",
|
|
||||||
summary: `${event.version} ${event.status}`,
|
|
||||||
};
|
|
||||||
case "header_up":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_up_dash",
|
|
||||||
color: "primary",
|
|
||||||
label: "Header",
|
|
||||||
summary: `${event.name}: ${event.value}`,
|
|
||||||
};
|
|
||||||
case "header_down":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_down_dash",
|
|
||||||
color: "info",
|
|
||||||
label: "Header",
|
|
||||||
summary: `${event.name}: ${event.value}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case "chunk_sent":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Chunk",
|
|
||||||
summary: `${formatBytes(event.bytes)} chunk sent`,
|
|
||||||
};
|
|
||||||
case "chunk_received":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Chunk",
|
|
||||||
summary: `${formatBytes(event.bytes)} chunk received`,
|
|
||||||
};
|
|
||||||
case "dns_resolved":
|
|
||||||
return {
|
|
||||||
icon: "globe",
|
|
||||||
color: event.overridden ? "success" : "secondary",
|
|
||||||
label: event.overridden ? "DNS Override" : "DNS",
|
|
||||||
summary: event.overridden
|
|
||||||
? `${event.hostname} → ${event.addresses.join(", ")} (overridden)`
|
|
||||||
: `${event.hostname} → ${event.addresses.join(", ")} (${event.duration}ms)`,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Unknown",
|
|
||||||
summary: "Unknown event",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user