mirror of
https://github.com/vrc-get/vrc-get.git
synced 2026-06-21 09:58:08 +00:00
Compare commits
1,973 commits
try-suppor
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a1044df51 |
||
|
|
e71c48141b |
||
|
|
467434903a | ||
|
|
ba8606d312 | ||
|
|
ed42cdfca4 | ||
|
|
dbd479fbc9 |
||
|
|
db54f0c8ae |
||
|
|
da419a0374 |
||
|
|
8351270552 |
||
|
|
50fccc9a50 |
||
|
|
cf7075a6d5 |
||
|
|
5e4709209f |
||
|
|
269cb8aeda |
||
|
|
ba9ad1f59c |
||
|
|
23b1bb2515 |
||
|
|
2c36473e6e | ||
|
|
791e50d94f | ||
|
|
a77ab4a0a3 |
||
|
|
aa14673468 |
||
|
|
36aa59b304 |
||
|
|
b61c44c64a |
||
|
|
0a5ab490b8 |
||
|
|
cfc1b627eb |
||
|
|
1b2065afe0 |
||
|
|
4634c42410 |
||
|
|
fd8b735ac9 |
||
|
|
580c8da047 |
||
|
|
0ebfb25263 |
||
|
|
9eaf99b3d1 |
||
|
|
87753ceffa |
||
|
|
0fa954cef0 |
||
|
|
41e25d4e86 |
||
|
|
3c499f9d6a |
||
|
|
65e37ff7af |
||
|
|
48e1866ff3 |
||
|
|
968ae63a4c |
||
|
|
805be35ac6 |
||
|
|
901368c9bf |
||
|
|
09c8190634 | ||
|
|
5eed047fa4 |
||
|
|
056073834c |
||
|
|
c4117c7457 |
||
|
|
3bf6b96b93 |
||
|
|
00fb4d8546 |
||
|
|
4d4c0c27d3 | ||
|
|
a31c947c1a |
||
|
|
f6e677909c | ||
|
|
8b58270235 | ||
|
|
c1b297e02a | ||
|
|
a0ec779fab |
||
|
|
800278a0c3 | ||
|
|
334492e5c6 | ||
|
|
1a947b7a6c | ||
|
|
514f52a419 | ||
|
|
3e42f0de4e | ||
|
|
6dfdd9c5f3 | ||
|
|
21a0e19722 | ||
|
|
6ab9c49c94 | ||
|
|
04adf285b5 | ||
|
|
665513d0fc |
||
|
|
6f8cd53159 |
||
|
|
ec6454b0d4 |
||
|
|
533a40b1eb | ||
|
|
90c7e249cd | ||
|
|
e057838795 | ||
|
|
437a63bcc6 | ||
|
|
836bf82351 |
||
|
|
316c73a6b6 | ||
|
|
ec04492af8 | ||
|
|
ebd117afd2 | ||
|
|
253a0266e6 | ||
|
|
0cf635e892 | ||
|
|
f8923dce1b |
||
|
|
9fc228eed0 |
||
|
|
54113144a8 | ||
|
|
0ab6c9434b | ||
|
|
9d94d8b929 | ||
|
|
d1887eb467 | ||
|
|
45ed34f573 | ||
|
|
930715960e | ||
|
|
8db24b7b7c | ||
|
|
44ed683c23 | ||
|
|
c248b0ed55 | ||
|
|
9d11a5e816 |
||
|
|
81d66143c9 | ||
|
|
6f143ac5e3 |
||
|
|
f44633a2ad |
||
|
|
3fb8281a47 |
||
|
|
244946c234 |
||
|
|
ccd252f8aa |
||
|
|
c2b2b9b491 |
||
|
|
9decfe400c |
||
|
|
35e7537a90 |
||
|
|
1800951c12 | ||
|
|
76b0f05651 |
||
|
|
f45ee6514e |
||
|
|
d421ee04b2 |
||
|
|
bd92649248 |
||
|
|
68be14aa90 |
||
|
|
dd9673c2cb |
||
|
|
73cfa96f45 |
||
|
|
fa22fc3611 |
||
|
|
8b61a0c806 |
||
|
|
c37758d4c1 |
||
|
|
37688599ae | ||
|
|
fcb0c5263d |
||
|
|
a7e19a6479 | ||
|
|
f0a7cbd050 |
||
|
|
a9322f4d64 |
||
|
|
0c895bf4a5 |
||
|
|
07e7279c31 |
||
|
|
a9e135b940 |
||
|
|
d2aca1554c |
||
|
|
5be2c13984 |
||
|
|
1b1a0eefae |
||
|
|
3e94514b13 |
||
|
|
56a32bad04 |
||
|
|
fb6a6538d7 |
||
|
|
ab64f403a5 |
||
|
|
96d8ced3bb |
||
|
|
bde10abd3b |
||
|
|
e3313f0327 |
||
|
|
bb0b8cda55 |
||
|
|
11b6b1cad4 |
||
|
|
92cff628c2 |
||
|
|
3f3465d74b |
||
|
|
d1c28f6479 |
||
|
|
089427cbfa |
||
|
|
375894c6c3 |
||
|
|
eed54886b8 |
||
|
|
11c1b7048b |
||
|
|
b613f834ea |
||
|
|
5d9c6ce802 |
||
|
|
f15e489515 |
||
|
|
687904918d |
||
|
|
c5d38a1f6e |
||
|
|
55635bbe9c |
||
|
|
ca30b889f1 |
||
|
|
0a17c9a1cd |
||
|
|
50aba0530e |
||
|
|
33be110c7a |
||
|
|
2aad318387 |
||
|
|
43ca6755ff |
||
|
|
92bc6cf38e |
||
|
|
8d293e5e69 |
||
|
|
1e9a100d9c |
||
|
|
8624950ca5 |
||
|
|
57c9a54d37 |
||
|
|
6470169cf1 |
||
|
|
795699baf0 |
||
|
|
7fbf8c60d1 |
||
|
|
a5c5d6e128 |
||
|
|
a3aa50256f |
||
|
|
498eedc1d6 |
||
|
|
c863bc4461 |
||
|
|
8fd964fa83 |
||
|
|
c7012c5e81 |
||
|
|
cf6c82adbd |
||
|
|
e0931be79a | ||
|
|
e0dce679d7 |
||
|
|
f9ca613378 |
||
|
|
0fa0c7ca00 |
||
|
|
9c1465868e |
||
|
|
874492f3fc |
||
|
|
8588a7276c |
||
|
|
0a5b9c38eb |
||
|
|
17a28fa609 |
||
|
|
099883a33d |
||
|
|
5cb60c4002 |
||
|
|
cdb1b8294d |
||
|
|
0de963736f | ||
|
|
0026f5029c |
||
|
|
987357354e |
||
|
|
f13add3409 |
||
|
|
2d7fefb34c |
||
|
|
e625630856 |
||
|
|
4943a671e0 |
||
|
|
6e63cdba25 |
||
|
|
e04d1ee919 |
||
|
|
a4992e3973 |
||
|
|
185b7aa8b7 |
||
|
|
24ae2b33de |
||
|
|
1b928b150b |
||
|
|
2d859d993e |
||
|
|
4018f718a7 |
||
|
|
de73063b98 |
||
|
|
d6ea9b3079 |
||
|
|
eaf09ecb6a |
||
|
|
d74c7b16b8 |
||
|
|
6c46983cae |
||
|
|
36b76933fc |
||
|
|
ad7efa259e |
||
|
|
97042ebc6a |
||
|
|
e135092745 |
||
|
|
5607daf7b5 | ||
|
|
2861fcaa68 | ||
|
|
8a71e426d6 |
||
|
|
cd242e389b |
||
|
|
95dc46666b |
||
|
|
f0c4de7d78 |
||
|
|
e9a994a932 |
||
|
|
803fbf3395 |
||
|
|
20d24b496b |
||
|
|
a1ff52fed4 |
||
|
|
4a99cfe4c9 |
||
|
|
8b13244b74 | ||
|
|
875a989d52 |
||
|
|
973c91c9c0 |
||
|
|
3d2dd2e058 |
||
|
|
16d8a787ec |
||
|
|
4ccc7d7bc8 |
||
|
|
8c8315f57d |
||
|
|
4cac964713 |
||
|
|
fb32bd97cd |
||
|
|
1a70d2db82 |
||
|
|
cacb9a5e9a |
||
|
|
4700bfa2c0 |
||
|
|
223be38368 |
||
|
|
523ede22ea |
||
|
|
38a7649fbd |
||
|
|
d61b46ded8 |
||
|
|
4756f6c115 |
||
|
|
e14806c72c |
||
|
|
8324a4ec04 |
||
|
|
a9f2a9713f |
||
|
|
40cd4487d5 |
||
|
|
56a2139df6 |
||
|
|
175008956c |
||
|
|
00a928e58e |
||
|
|
7037a758bc |
||
|
|
f457ffcc69 |
||
|
|
9291853c19 |
||
|
|
0e6d011ee5 |
||
|
|
be5c9aa765 |
||
|
|
b090c440ac |
||
|
|
3c51a2de31 |
||
|
|
04fa1f8244 |
||
|
|
35b26d9322 |
||
|
|
478c25b03a |
||
|
|
20e5836985 |
||
|
|
ce8de700a7 |
||
|
|
3076713ea6 |
||
|
|
75974f4ad9 |
||
|
|
4eadf2bdcd |
||
|
|
c43ed4a828 |
||
|
|
f675d29173 |
||
|
|
c45e4e399f |
||
|
|
f5f7ac794e |
||
|
|
88fd6a06a7 |
||
|
|
c3020225f3 |
||
|
|
d2f9a39b67 |
||
|
|
776bd4b4e8 |
||
|
|
4ae3507f96 |
||
|
|
7b105639f9 |
||
|
|
fc177add7c |
||
|
|
4063021e2a |
||
|
|
463b4c5df7 |
||
|
|
bbbbd19926 |
||
|
|
3d7b2b8e22 |
||
|
|
b24bcf1c38 |
||
|
|
19bde29c6b |
||
|
|
f18bc14777 |
||
|
|
07a5eb27b3 |
||
|
|
6a5c60a79e |
||
|
|
2b04760299 |
||
|
|
1845e8083d |
||
|
|
faf432fd61 |
||
|
|
45942829df | ||
|
|
b64e09dc55 |
||
|
|
dee9a2fb78 |
||
|
|
508d62b3e6 |
||
|
|
f119b203f9 |
||
|
|
a64cb3af9b |
||
|
|
70f7f8c78e |
||
|
|
b64058cab0 |
||
|
|
3e8d5cc9d9 |
||
|
|
2809d94996 |
||
|
|
dccd85be31 |
||
|
|
726ca0c068 |
||
|
|
5887c5631c |
||
|
|
a57de0d7f1 |
||
|
|
9bc7d26fff |
||
|
|
20c20a49b0 |
||
|
|
6a4648ddbb |
||
|
|
4783761910 |
||
|
|
1fdb97d04a |
||
|
|
b3430d926b |
||
|
|
4edda28776 |
||
|
|
b8903da155 |
||
|
|
38ac981d22 |
||
|
|
9fa4d25637 |
||
|
|
490190606c |
||
|
|
fd6eb27b09 |
||
|
|
5fd5109f3b |
||
|
|
496a1c91c1 |
||
|
|
42f1835360 |
||
|
|
4b89aec67a |
||
|
|
422b4b1b8d |
||
|
|
98cde02985 |
||
|
|
b6cb3f51f7 |
||
|
|
50bc72d662 |
||
|
|
9655865ba9 |
||
|
|
827042c827 |
||
|
|
e265b8fcdb |
||
|
|
3394e59e49 |
||
|
|
dccfbc2867 |
||
|
|
1cbc34bb30 |
||
|
|
61832aa154 |
||
|
|
b696c7cc31 |
||
|
|
847d76cb5c | ||
|
|
a29a9bc9e2 |
||
|
|
72103a67a5 |
||
|
|
7f1be1818e |
||
|
|
6a7d60d89c |
||
|
|
7df14594d1 |
||
|
|
001a6a330e |
||
|
|
9ff7c8e1e7 |
||
|
|
17f315a44a |
||
|
|
d2cdc980e2 |
||
|
|
9c9e8b8f9c |
||
|
|
b7dc45c2c3 |
||
|
|
ebb17f10e3 |
||
|
|
da26f04917 |
||
|
|
930c79d649 |
||
|
|
f19b9d0084 |
||
|
|
c771de380a |
||
|
|
bb26d062de |
||
|
|
41b1514f66 |
||
|
|
09aebbcb8b |
||
|
|
df8174341e |
||
|
|
93012d8bd1 |
||
|
|
9a739177bc |
||
|
|
34ef49a629 |
||
|
|
647799180a |
||
|
|
e00162180f |
||
|
|
1567b9dec2 |
||
|
|
2e5962422a |
||
|
|
fb292da426 |
||
|
|
98b8df7bb2 |
||
|
|
a5355dd287 |
||
|
|
8915177909 |
||
|
|
ad232e3506 |
||
|
|
a8aed0a011 |
||
|
|
79b7776e40 |
||
|
|
d96ae23a60 |
||
|
|
c44bbe2584 |
||
|
|
fa05c2bc30 |
||
|
|
9b26a5fdb3 |
||
|
|
c8b0cf89c8 |
||
|
|
911c0a5e51 |
||
|
|
8773f3d3e6 |
||
|
|
974a195a3c |
||
|
|
9b63d1919f |
||
|
|
04b65168b8 |
||
|
|
f2ce2829eb |
||
|
|
d3d127efb8 |
||
|
|
98a9bf6938 |
||
|
|
56f586974e | ||
|
|
2f6e52032e |
||
|
|
09d60c804f |
||
|
|
8c210c248f | ||
|
|
3f6ba916c0 | ||
|
|
3213f9288a |
||
|
|
2411f7482b |
||
|
|
7ac0b41800 |
||
|
|
03f1140d71 |
||
|
|
3e58fc6d1a |
||
|
|
fe0f711d62 |
||
|
|
dbd5fbbca8 |
||
|
|
da12ba3cad |
||
|
|
1178e151ef |
||
|
|
0cbbaacd12 |
||
|
|
bd7f84196b |
||
|
|
8babbb0e04 |
||
|
|
63d40420c9 |
||
|
|
6afa1e07b5 |
||
|
|
6f5359c7a3 |
||
|
|
60f20b2b22 |
||
|
|
25e19296d0 |
||
|
|
fd66eee442 |
||
|
|
f2809b0d19 |
||
|
|
91d8b73fad |
||
|
|
5ac23e1ca3 |
||
|
|
0961bcad1d |
||
|
|
4c8ccea7c3 |
||
|
|
68ff4c3205 |
||
|
|
b30df5621a |
||
|
|
02759caf1c |
||
|
|
9df7055ee1 |
||
|
|
60636fb8c3 |
||
|
|
1b7263e35e |
||
|
|
5937594af1 |
||
|
|
4da483032e |
||
|
|
93cb37ff77 |
||
|
|
0ae87b7b49 |
||
|
|
dfe9e97b00 |
||
|
|
51b7f9ccee |
||
|
|
44a5f5eadf |
||
|
|
a89bcbfa16 |
||
|
|
3436711467 |
||
|
|
dd160c5199 |
||
|
|
4e31d23b0c |
||
|
|
6fb9f631a1 |
||
|
|
fca8805043 |
||
|
|
9d533a2051 |
||
|
|
f7a0099b53 |
||
|
|
d98339017f |
||
|
|
6bc973dd66 |
||
|
|
7fca14868f |
||
|
|
a9378ac341 |
||
|
|
09bca3b5fd |
||
|
|
001880b49d |
||
|
|
bcb7131054 |
||
|
|
b5d93a3bab |
||
|
|
6c411dd7a2 |
||
|
|
3e3b2155cd |
||
|
|
95d8af8559 |
||
|
|
358ca77692 |
||
|
|
8ec535701c |
||
|
|
07472f8622 |
||
|
|
0680a57066 |
||
|
|
88fa1c2b3d |
||
|
|
b38f2393fd |
||
|
|
b00beaae70 |
||
|
|
d349e16411 |
||
|
|
13e53bfba0 |
||
|
|
281669e6c5 |
||
|
|
30d49406e4 |
||
|
|
3fce6e45ea |
||
|
|
2c8eeec473 |
||
|
|
ee527cf95c |
||
|
|
02fe4e64ac |
||
|
|
99d526e0cf |
||
|
|
16b48bb1db |
||
|
|
3e3ffabf7e |
||
|
|
fcbcf3a504 |
||
|
|
370dc0f3d8 |
||
|
|
8619197e92 |
||
|
|
205a60aafc |
||
|
|
5c0d2789d8 |
||
|
|
3790d10f96 |
||
|
|
72e927edd3 |
||
|
|
506b389e40 |
||
|
|
22a40d64cd |
||
|
|
4586b5f5b3 |
||
|
|
9a46a49c36 |
||
|
|
bf53c697a3 |
||
|
|
96fd94e2a1 |
||
|
|
bbda611499 |
||
|
|
083d320cf1 |
||
|
|
d79cd05fe6 |
||
|
|
1411a7405f |
||
|
|
7639563544 |
||
|
|
4c8f24da3f | ||
|
|
1ae45e3b47 |
||
|
|
19ef64ef32 | ||
|
|
650d0bf8ff | ||
|
|
7ba6106a63 | ||
|
|
52234179f3 | ||
|
|
976fd95517 | ||
|
|
ee2a73994e | ||
|
|
439a639d15 | ||
|
|
9c57629211 | ||
|
|
fa7dc36c83 |
||
|
|
06cdd8d5cc |
||
|
|
d28d8dcdc6 |
||
|
|
23d087e6cc |
||
|
|
a657a77633 |
||
|
|
6e0b28ee2c |
||
|
|
727f963923 |
||
|
|
7229201ad3 |
||
|
|
042dfa9f69 |
||
|
|
bdd312152c |
||
|
|
00051c6e53 |
||
|
|
4f6cee33c2 | ||
|
|
02fcb00bb3 |
||
|
|
0e62041ab4 |
||
|
|
06fe826379 |
||
|
|
57ae7c7df8 |
||
|
|
40b3edc0e2 |
||
|
|
7f1d7f36e8 |
||
|
|
dd645a5b5b |
||
|
|
da81afe549 |
||
|
|
e29fafa6d2 |
||
|
|
571a6e71fb | ||
|
|
27835f9ce0 |
||
|
|
b0a1f5b6d9 |
||
|
|
a9474fae66 |
||
|
|
301ae004b6 |
||
|
|
d32d7f854a | ||
|
|
7eda8cedde | ||
|
|
0f63d3bc4e | ||
|
|
9cb256d4ec | ||
|
|
2a87edcaa3 |
||
|
|
e543d5329f | ||
|
|
0943241884 |
||
|
|
d0a90046b7 |
||
|
|
9922e0da2c |
||
|
|
2cc9c692d7 |
||
|
|
93609bb42d |
||
|
|
22f50c3e05 |
||
|
|
f0854c9a78 |
||
|
|
82d4babc13 |
||
|
|
8b7df92640 |
||
|
|
ed26112f7e |
||
|
|
40a1f6fef7 |
||
|
|
7b259d47f4 |
||
|
|
faf15cd44a |
||
|
|
40d0786b19 |
||
|
|
7fdd1192e1 |
||
|
|
d09244291f |
||
|
|
eae4745ce6 |
||
|
|
9554373332 |
||
|
|
22d757ed52 |
||
|
|
9fbce2a575 |
||
|
|
9257f1fed5 |
||
|
|
b52b04a81e |
||
|
|
3d4757f267 |
||
|
|
9dd4a13596 |
||
|
|
2e2ee22add |
||
|
|
f852312336 |
||
|
|
0d82e0e5ec |
||
|
|
d5434497da |
||
|
|
78d6ff1a3c |
||
|
|
205c9e66d7 |
||
|
|
e5eee65f66 |
||
|
|
714b1b3b8a |
||
|
|
fa0a5e4e37 |
||
|
|
9a430541df |
||
|
|
20489da9d6 |
||
|
|
2f1755cb03 |
||
|
|
a3b44389ee |
||
|
|
ca4cacbfff |
||
|
|
fc0b40a27f |
||
|
|
063639e389 |
||
|
|
b0aafd0411 |
||
|
|
87f784b613 |
||
|
|
9f0ba1abfb |
||
|
|
a4a86b0ab1 |
||
|
|
a69ecb0cf1 |
||
|
|
bb44461bab |
||
|
|
4332876651 |
||
|
|
50e38cebbe |
||
|
|
88a0b77e5d |
||
|
|
fe7d9d776c |
||
|
|
9e3171b56c |
||
|
|
df7d7e0f56 |
||
|
|
bfe8041047 |
||
|
|
96248ade9d |
||
|
|
c8e8ab51d5 |
||
|
|
d746140499 |
||
|
|
ce829ccae4 |
||
|
|
b231c500a3 |
||
|
|
911541bac4 |
||
|
|
5c2385ea96 |
||
|
|
29c076dc9a |
||
|
|
fbe3d42492 |
||
|
|
7d74bcec30 |
||
|
|
e8505ace12 |
||
|
|
2faca4c5a2 |
||
|
|
5252b0e0e6 |
||
|
|
8d914e3cf4 |
||
|
|
bdc5a8ac36 |
||
|
|
483f8dd0fd |
||
|
|
b570f01931 |
||
|
|
d5ae35c5c1 |
||
|
|
345006328d |
||
|
|
d34a1ff529 |
||
|
|
408916325b |
||
|
|
072e401d82 |
||
|
|
a7dbf1e85e |
||
|
|
42031f1716 |
||
|
|
3ad5ccb200 |
||
|
|
86e5529b0e |
||
|
|
f0440d3c40 |
||
|
|
4695d60b56 |
||
|
|
f846d2c41f |
||
|
|
cc55272841 |
||
|
|
8c85a9cb13 |
||
|
|
21ed073c99 |
||
|
|
1544967281 |
||
|
|
d0d8cdaf19 |
||
|
|
c3291d9993 |
||
|
|
1de47356ad |
||
|
|
9a6475f001 |
||
|
|
7c6ee29928 |
||
|
|
2753ffa3db |
||
|
|
2228b08b3a |
||
|
|
aa9dbb0057 |
||
|
|
61e2a9a78d |
||
|
|
b6ac50cc74 |
||
|
|
377b4ce340 |
||
|
|
18fc1fa4bd |
||
|
|
6e2caedc2d |
||
|
|
b39242c710 |
||
|
|
30c7fb2c1b |
||
|
|
03771c8be3 |
||
|
|
b332d12e0b |
||
|
|
df1fc5a591 |
||
|
|
28be7e8202 |
||
|
|
23f09836a0 |
||
|
|
a9ff68b47a |
||
|
|
edb8c2d2d9 |
||
|
|
3ce9fd58d3 |
||
|
|
73998e7f2c |
||
|
|
51aa98ab2b |
||
|
|
df3efbcf65 |
||
|
|
3f28dbe371 |
||
|
|
0abcadb979 |
||
|
|
e5d7c492d2 |
||
|
|
670642d8cb | ||
|
|
d2cabbdc0b | ||
|
|
257a479f3e | ||
|
|
add44e04a9 | ||
|
|
c1eb9ffc16 | ||
|
|
6104fc017d | ||
|
|
be4d898668 |
||
|
|
a21606f011 |
||
|
|
5859403cad |
||
|
|
b9596cb56a | ||
|
|
9c2963b21c | ||
|
|
194ae0b96d | ||
|
|
d370a5195c |
||
|
|
85edddbf0d |
||
|
|
ceaf3a9cce |
||
|
|
ad5f28ce6a |
||
|
|
6251e87fbe |
||
|
|
a239686702 |
||
|
|
6f4ded7ed7 |
||
|
|
912609d681 |
||
|
|
a64426984c |
||
|
|
7a30129796 |
||
|
|
c706ce7b27 |
||
|
|
933933e287 | ||
|
|
f5fa8a5e2f | ||
|
|
d120156b50 |
||
|
|
0c50a14ba7 |
||
|
|
274ef9f33d |
||
|
|
94971631a3 |
||
|
|
3aadcf21d7 |
||
|
|
beffbeae47 |
||
|
|
8b0956c6df |
||
|
|
4db0e64e81 |
||
|
|
c679ed69fa |
||
|
|
1394691f4a |
||
|
|
229118fb9e |
||
|
|
54e4ad3756 |
||
|
|
ed9a12ee80 | ||
|
|
dbe60c6a6e | ||
|
|
caec80ce05 |
||
|
|
3c3dc35ea3 | ||
|
|
03cac441dd | ||
|
|
45a9ab02fb |
||
|
|
35bc7ff4a5 |
||
|
|
447f371598 |
||
|
|
2f71bcf9d3 |
||
|
|
b42e4c29a1 |
||
|
|
033d8ea05c | ||
|
|
0a64ce7194 |
||
|
|
2362b55407 |
||
|
|
420e640f00 | ||
|
|
3e60bf15e9 |
||
|
|
7e240df2c5 |
||
|
|
da4efddb1c |
||
|
|
a4e447021c |
||
|
|
693fc96583 |
||
|
|
68b63967db |
||
|
|
50e458c5c6 |
||
|
|
3204c31447 |
||
|
|
416448ddec |
||
|
|
b1ab5c46c0 |
||
|
|
b4eb2e4116 |
||
|
|
3ecd463963 |
||
|
|
bcb2c9253b |
||
|
|
874800f371 |
||
|
|
18105daae5 |
||
|
|
1713fb6f1c |
||
|
|
c675d975bd |
||
|
|
6c9900ed69 |
||
|
|
2d83e0dade |
||
|
|
048ad943d4 |
||
|
|
a1b16b6ac6 |
||
|
|
518a8c671b |
||
|
|
a7170b42c5 |
||
|
|
ed70604b38 |
||
|
|
30fa3e7bef |
||
|
|
a0e08ce48f | ||
|
|
d185db333c | ||
|
|
86f1afa140 | ||
|
|
8485f35e17 | ||
|
|
abcffbd849 | ||
|
|
b7e6d97484 | ||
|
|
9026343cdd | ||
|
|
74062136fd |
||
|
|
ec66e5c2f0 |
||
|
|
2e5c5da3cb |
||
|
|
0f0f0aaddb |
||
|
|
5b7861a91e | ||
|
|
c90c495a31 | ||
|
|
c477121bc5 |
||
|
|
7ff3b16c8e | ||
|
|
d7b126f82a |
||
|
|
6af3d96cf2 | ||
|
|
6a258a19b3 |
||
|
|
544a9bd505 | ||
|
|
40ce7de7b7 |
||
|
|
6866b6f08c | ||
|
|
bcdb94861b | ||
|
|
c32b59b9bf |
||
|
|
b097013eba | ||
|
|
d7d369b5fe | ||
|
|
520b79e0dd | ||
|
|
52d3e158d3 | ||
|
|
0a599c806b | ||
|
|
16f65ec950 | ||
|
|
09ccfb9f4f |
||
|
|
4d7a923eaf | ||
|
|
3c6385e75f | ||
|
|
8331d1d978 | ||
|
|
b1d28f4efc | ||
|
|
54fbb7ceff | ||
|
|
dce7c74f45 | ||
|
|
4abb31287f | ||
|
|
a7bf336674 | ||
|
|
0cb111bba8 | ||
|
|
68b1ed245b | ||
|
|
f769dd45a6 |
||
|
|
261f3a97cb | ||
|
|
3399b0fa00 | ||
|
|
e89e1340a0 | ||
|
|
70b0c7c7e6 | ||
|
|
6eb2422362 | ||
|
|
7b9f009e17 | ||
|
|
12f4244814 |
||
|
|
cc139a07e1 |
||
|
|
42e3ba176d | ||
|
|
0925dcbca6 |
||
|
|
2edaa8c846 | ||
|
|
f1c0afd6cc |
||
|
|
1a03dea7c9 |
||
|
|
1ef62e6f79 |
||
|
|
ef33365884 |
||
|
|
b27a9ce01d | ||
|
|
13174eba98 |
||
|
|
0dbf59dd85 | ||
|
|
7a5aed016d |
||
|
|
8e703a718b |
||
|
|
a78ebd4f52 |
||
|
|
3342e069dd |
||
|
|
bde51b52e3 |
||
|
|
357ead18ce |
||
|
|
7c8a40bd2b |
||
|
|
56d883fa6d |
||
|
|
f69d968680 |
||
|
|
d5b4c28b34 |
||
|
|
d7c8cea78a |
||
|
|
aebaf83dde |
||
|
|
13feb4e889 |
||
|
|
cbcd0a5268 | ||
|
|
88b8043084 | ||
|
|
fe95e0f893 |
||
|
|
d741270d43 |
||
|
|
ba826a9856 | ||
|
|
73a802103e |
||
|
|
ecc94f9771 |
||
|
|
92e71a4929 |
||
|
|
43869ae594 |
||
|
|
9e23a3f794 |
||
|
|
5f57cd6954 |
||
|
|
19fc2ea44f |
||
|
|
77fc446770 |
||
|
|
28b585a754 |
||
|
|
f0599e37f6 |
||
|
|
08b4b83cf6 |
||
|
|
eddf19d989 |
||
|
|
8830701a43 |
||
|
|
8cc0f2c865 |
||
|
|
50376cd537 |
||
|
|
acc97794c7 |
||
|
|
4e85bd674b |
||
|
|
6085d0c8da |
||
|
|
49d2383d0b |
||
|
|
3a059b8076 |
||
|
|
3ea86ca01a |
||
|
|
b06d839f6f |
||
|
|
889abbf080 |
||
|
|
e27c81527b |
||
|
|
2afa04625a |
||
|
|
264e7264d2 |
||
|
|
fed69c51ae |
||
|
|
be449abcb0 |
||
|
|
d36ad4d5f6 |
||
|
|
52bf5045d1 |
||
|
|
62d65154ac |
||
|
|
a18ee8d27b |
||
|
|
926549727b |
||
|
|
990c7a4792 |
||
|
|
13eb2f2d80 |
||
|
|
f0d77b3c4d |
||
|
|
6c7e362853 |
||
|
|
f229e4fcfe |
||
|
|
99416f5530 |
||
|
|
b2dc61a903 |
||
|
|
44d6669d3c |
||
|
|
19636c3ee0 |
||
|
|
f717df3e52 |
||
|
|
bd79f1a3d7 |
||
|
|
ab9f9a4ae5 |
||
|
|
4bcaa4e564 |
||
|
|
3c79f0c4c1 |
||
|
|
bd934ed698 |
||
|
|
97be14fb5c |
||
|
|
08309f1e80 |
||
|
|
b8412ea607 |
||
|
|
7f0afbf3af |
||
|
|
63e4ff482b |
||
|
|
ff7f8a5dab |
||
|
|
c398d70326 |
||
|
|
4e30f45a3b |
||
|
|
4133a7550b |
||
|
|
9c99b7d19e |
||
|
|
0d97b76156 |
||
|
|
e0288af16c |
||
|
|
8b2e029df8 |
||
|
|
5fb465b9e8 |
||
|
|
b75d4772bf |
||
|
|
45260b65bb |
||
|
|
4b05207f5c |
||
|
|
3ea727bc7d |
||
|
|
de7941e238 | ||
|
|
2f17a3f71e | ||
|
|
9c7d6e188d | ||
|
|
09425bb82e |
||
|
|
e2918bd233 |
||
|
|
803b7ca0e3 |
||
|
|
1577bdff55 |
||
|
|
d190956c0f |
||
|
|
b0d32afb04 |
||
|
|
e2c20bb741 |
||
|
|
d02734aab8 |
||
|
|
627f1bb565 |
||
|
|
942cf708e5 |
||
|
|
963ed90aec |
||
|
|
454dd753f3 |
||
|
|
62d3ad81f1 |
||
|
|
6a7bf4187e |
||
|
|
e22519b94a | ||
|
|
7d20291448 | ||
|
|
d00609643c |
||
|
|
22e5ed0eb0 |
||
|
|
70000416e0 | ||
|
|
7e4a235dca |
||
|
|
48699d9a88 |
||
|
|
1207ad493f |
||
|
|
3e9f1f2814 |
||
|
|
b2f9d84909 | ||
|
|
a06b66ce56 |
||
|
|
33d01b8796 |
||
|
|
b973b00042 |
||
|
|
3db966c958 |
||
|
|
136dab4639 |
||
|
|
8dbd61ad9a |
||
|
|
af33c544a9 |
||
|
|
9b3197ca8d |
||
|
|
370239fb75 |
||
|
|
a71c490483 | ||
|
|
3fa792d9a1 | ||
|
|
16b8a5c9df | ||
|
|
58d572be01 |
||
|
|
007e9596f5 | ||
|
|
b1aff93c98 | ||
|
|
14082a1f11 | ||
|
|
f8cfe99e6a | ||
|
|
5be5b76212 |
||
|
|
1a41db278d |
||
|
|
42ff3bc1cc | ||
|
|
083ac23265 | ||
|
|
0b40d291f1 |
||
|
|
5bcb59c4a9 |
||
|
|
1e35ee34db |
||
|
|
6d336ee757 | ||
|
|
50de673c8b |
||
|
|
69b9eae851 | ||
|
|
d28d3def35 | ||
|
|
77c4634b42 |
||
|
|
8ab5564bc0 |
||
|
|
c5a86c2a35 |
||
|
|
015319f1c6 | ||
|
|
6cb1db9435 |
||
|
|
8764df161a |
||
|
|
05db375b8d | ||
|
|
52098f29cf |
||
|
|
cb8b515e69 |
||
|
|
515c19c3d5 |
||
|
|
764d2957b6 |
||
|
|
abf8bd467b |
||
|
|
51c9c0b313 | ||
|
|
c239d51612 | ||
|
|
2978813828 | ||
|
|
18878b743a | ||
|
|
10f482845c | ||
|
|
ef374fb611 |
||
|
|
458632b261 |
||
|
|
7197805e84 |
||
|
|
2ba14b45de |
||
|
|
b901fc0180 |
||
|
|
6c66c8ecd9 |
||
|
|
ffd1efbcc4 |
||
|
|
51716471b3 |
||
|
|
4facd40148 |
||
|
|
20bdbca448 |
||
|
|
5fa2c9cf92 |
||
|
|
18281f6a6d |
||
|
|
73ecf2f052 |
||
|
|
b082f2d8df |
||
|
|
94e65ef930 |
||
|
|
5b921a9808 |
||
|
|
d2fde29b20 |
||
|
|
203e48fb0b |
||
|
|
57b976a300 |
||
|
|
91bbe1e55c |
||
|
|
f344813ff8 |
||
|
|
8862d44f79 |
||
|
|
1431b54810 |
||
|
|
1050a2ccfa |
||
|
|
c9dc6f05f8 |
||
|
|
26e9bb708b | ||
|
|
661019985b | ||
|
|
536884d107 |
||
|
|
26ded7aa24 |
||
|
|
262cb4c80c |
||
|
|
816e7a6516 | ||
|
|
6afc1c3532 |
||
|
|
8ebd0ca2f5 |
||
|
|
7aa986be92 |
||
|
|
3c4547e7a7 |
||
|
|
89304565a2 | ||
|
|
4c2df92dfe | ||
|
|
9a5b4b11e5 |
||
|
|
3c8d230ee1 |
||
|
|
f0ed11bdf7 |
||
|
|
feb3a1f851 |
||
|
|
55ce29edf8 |
||
|
|
7ad5771d94 |
||
|
|
89b757b538 |
||
|
|
d9be937118 |
||
|
|
5d17ef477c |
||
|
|
01ba80ddcd |
||
|
|
63fd4ce399 |
||
|
|
4d956e126e | ||
|
|
73aa231482 |
||
|
|
c1a43e42d7 | ||
|
|
ac70c654e7 |
||
|
|
1ab8d878a2 |
||
|
|
23f553451b | ||
|
|
d050341d7d |
||
|
|
13ec305289 |
||
|
|
b2dc8ea1ab |
||
|
|
886ce42567 |
||
|
|
b5dd8b2a92 |
||
|
|
0640b13cc8 |
||
|
|
8b8c7ad132 |
||
|
|
99766d08f3 |
||
|
|
e24fe409e2 |
||
|
|
bd8714afd8 |
||
|
|
5f2805b1d7 |
||
|
|
85004cb650 |
||
|
|
417c882c5c |
||
|
|
77734f76b4 |
||
|
|
52f72867df |
||
|
|
b023aa67aa |
||
|
|
4f8ef9f110 | ||
|
|
c51c4cd967 |
||
|
|
aa9b82c871 | ||
|
|
cc0c2a373e | ||
|
|
888d16297f |
||
|
|
49128107e8 | ||
|
|
93a21025ce | ||
|
|
def057fdb6 | ||
|
|
2993149ace | ||
|
|
3181bd4cf3 |
||
|
|
2bc0c7f006 |
||
|
|
9629d87b56 |
||
|
|
0babc534a8 | ||
|
|
99598f395c | ||
|
|
7468933294 |
||
|
|
0abc67eb81 |
||
|
|
3dae53cdbb |
||
|
|
b28f198c56 | ||
|
|
dbccc6cfd2 |
||
|
|
aeef431428 | ||
|
|
b3be48d457 |
||
|
|
97b312507e |
||
|
|
59f5be7626 |
||
|
|
0999d8faea |
||
|
|
6799b0c0ea |
||
|
|
45f37e04de |
||
|
|
8a779f116b |
||
|
|
e38890bf70 |
||
|
|
f09a60742f |
||
|
|
ebf4e1f75f |
||
|
|
27982ca202 |
||
|
|
b94f993ca5 |
||
|
|
83b51e862e |
||
|
|
4cad684232 |
||
|
|
657a132088 |
||
|
|
56d3478774 |
||
|
|
c3a8ab32ec |
||
|
|
4eec379ea3 |
||
|
|
0f43972dcc |
||
|
|
db9a136474 |
||
|
|
22324777ab | ||
|
|
fcfc79ed14 | ||
|
|
d4b3d3fc23 | ||
|
|
88d8adcd7b |
||
|
|
d08b337c5b | ||
|
|
3106912f6b | ||
|
|
bdecb54898 | ||
|
|
7f6c10425c |
||
|
|
6c6b18e1db | ||
|
|
e310699e1d |
||
|
|
9b7082acf3 |
||
|
|
e26cce8cf4 |
||
|
|
534110a5be | ||
|
|
6901f6efab |
||
|
|
617f9f3dc2 |
||
|
|
374240b2be |
||
|
|
830e2f868b |
||
|
|
b1987328c7 |
||
|
|
adc224723b | ||
|
|
577a0c9398 | ||
|
|
d109ddf2a5 |
||
|
|
6591e35bcf |
||
|
|
a761e8f544 |
||
|
|
8d3ed1fdef |
||
|
|
1aaaccda8c |
||
|
|
a6ad974881 |
||
|
|
139619dee5 |
||
|
|
9cf28f1c3e |
||
|
|
62e4b372f0 | ||
|
|
19d6c05dc0 |
||
|
|
999c624c6b |
||
|
|
e622744c42 | ||
|
|
25976f402c | ||
|
|
747acff8a5 | ||
|
|
70b56fea19 | ||
|
|
905d2312a0 | ||
|
|
43de48df65 | ||
|
|
5ccb54b956 |
||
|
|
3910faf693 |
||
|
|
82ca0820d8 |
||
|
|
de8ba426b7 |
||
|
|
a36598bc7a | ||
|
|
a89f0b1d76 |
||
|
|
073c8ebc1a |
||
|
|
e160a9bfce |
||
|
|
081079117a |
||
|
|
f76cd350fd |
||
|
|
7b5f16c38b |
||
|
|
0152adc681 |
||
|
|
9c82184983 | ||
|
|
d50912c9e1 |
||
|
|
6cfb174995 |
||
|
|
81d155cb30 |
||
|
|
e194f78d55 |
||
|
|
e86c6ca63a |
||
|
|
869a7043ab |
||
|
|
e23399b29a |
||
|
|
5a1f5b58cd |
||
|
|
0ea91c8a4b |
||
|
|
edbb579a35 |
||
|
|
5bf5fefbd6 |
||
|
|
ce7e09a667 |
||
|
|
ed983daf2c |
||
|
|
6531309b9d |
||
|
|
0cbb35c2ba |
||
|
|
1d60a620ab |
||
|
|
81b5f48610 |
||
|
|
7154062948 |
||
|
|
553fa8b972 |
||
|
|
64e95677f6 |
||
|
|
db5ea5e872 |
||
|
|
9c11585dc7 |
||
|
|
712cd48287 |
||
|
|
548c1f4240 |
||
|
|
13e729c060 | ||
|
|
5b4eab46f9 |
||
|
|
818312c90a |
||
|
|
94d70aa649 | ||
|
|
f4a0824491 |
||
|
|
2353dba140 |
||
|
|
312b756d2a |
||
|
|
e038db5c6c |
||
|
|
e53438a9d3 |
||
|
|
ab98620524 | ||
|
|
e10d9ff0db | ||
|
|
7627a795fe | ||
|
|
ea99157ab8 |
||
|
|
cbc42c0bd2 |
||
|
|
496f6618ab | ||
|
|
5381f06388 |
||
|
|
861ad01b14 |
||
|
|
93037d1479 |
||
|
|
cadb379775 |
||
|
|
62490077a0 |
||
|
|
7ad56dfb0c |
||
|
|
f0c9365209 |
||
|
|
53c7f0a236 |
||
|
|
c65a3a853b |
||
|
|
53c99829c4 |
||
|
|
26c61bc1a9 |
||
|
|
cb1f8c23cb |
||
|
|
56f7c1aeae |
||
|
|
98a6b8c60f |
||
|
|
465f0cdbbb |
||
|
|
7d4bdef61e |
||
|
|
f2f7305ca3 |
||
|
|
c42474707b |
||
|
|
20d0691c36 |
||
|
|
dad01fdc7d |
||
|
|
9476f019b4 |
||
|
|
82116993c6 |
||
|
|
59609a3419 |
||
|
|
a3c4cb2dd3 |
||
|
|
d291cddc9f |
||
|
|
ac393960a9 |
||
|
|
456a9080a9 |
||
|
|
b81bc4f394 |
||
|
|
26069192f0 |
||
|
|
3e7b580627 |
||
|
|
72d6c60b0e |
||
|
|
99d9a7cc16 |
||
|
|
47c5f5215d |
||
|
|
4e828f2c1e |
||
|
|
372cde6583 |
||
|
|
5caea23f4e |
||
|
|
334f1ac290 |
||
|
|
13b25b935f |
||
|
|
58df038df2 |
||
|
|
3b62014c02 |
||
|
|
195eb88507 |
||
|
|
78fb37e997 |
||
|
|
669fd05bf2 |
||
|
|
65e1227eba |
||
|
|
5be49d3d7e |
||
|
|
582efe9b57 |
||
|
|
206ca4d9c8 |
||
|
|
ad767a10d7 |
||
|
|
d51a348240 |
||
|
|
1576290574 |
||
|
|
1fb67a994d |
||
|
|
d6a4c933e5 |
||
|
|
9e52ed7c28 |
||
|
|
32e5239662 |
||
|
|
77a5abf63b |
||
|
|
1acbe99f7c |
||
|
|
185c676317 |
||
|
|
99e3aa6c11 |
||
|
|
655f8a6df2 |
||
|
|
e9c8b6f62f |
||
|
|
78e690a246 |
||
|
|
06a2d8b795 |
||
|
|
242f4034b9 |
||
|
|
3e1076a833 |
||
|
|
91a61fcf48 |
||
|
|
5239dd93e1 |
||
|
|
11f956cfa4 |
||
|
|
5c8b3c4ab9 |
||
|
|
a979b96586 | ||
|
|
46693caaba |
||
|
|
2a6990faac |
||
|
|
01bc1d6614 |
||
|
|
9a08258123 |
||
|
|
7814ba7566 |
||
|
|
b20ac31a0a |
||
|
|
707b778643 | ||
|
|
1d4197d6dd | ||
|
|
a32106c1b8 |
||
|
|
bfc030d5aa | ||
|
|
05e5e8fefd | ||
|
|
7604c4b559 |
||
|
|
9c1cd87a33 |
||
|
|
5ac6c827b1 |
||
|
|
760938216a |
||
|
|
e346444058 |
||
|
|
38a7eede39 |
||
|
|
793d058167 |
||
|
|
b624ea119d |
||
|
|
b8bac1bd96 |
||
|
|
e074030490 |
||
|
|
c86df50c49 |
||
|
|
841cf2027e |
||
|
|
0c65e1b156 |
||
|
|
10b979d8e6 |
||
|
|
2a6a43d8f9 |
||
|
|
cee0633cd3 |
||
|
|
75e52e9f6f |
||
|
|
2e485ebca4 |
||
|
|
18091becee |
||
|
|
7076dff6ab |
||
|
|
b298da3ef4 |
||
|
|
19d39ace4a |
||
|
|
bb5d5eb4bb |
||
|
|
2ae75af409 |
||
|
|
26614e052d |
||
|
|
c6da8808d0 |
||
|
|
b4a9367faf |
||
|
|
49399a8cdc |
||
|
|
1f89ae2e91 |
||
|
|
e21d87e045 |
||
|
|
1e9f756dfb |
||
|
|
f5e6160ba2 |
||
|
|
80c089e9f3 |
||
|
|
6acdd36e9a |
||
|
|
bb68caea18 |
||
|
|
096cf176a0 |
||
|
|
8e0bf595fc |
||
|
|
3ae3e8e95f |
||
|
|
ac73e2461d | ||
|
|
e175eb46ae | ||
|
|
05948227ad |
||
|
|
2f8eddbaee |
||
|
|
743601011f |
||
|
|
bd22482e23 |
||
|
|
f7c16d63a9 |
||
|
|
e9f9200790 |
||
|
|
df2491dd58 |
||
|
|
e78d483081 |
||
|
|
7ba79f6355 |
||
|
|
87f626d276 |
||
|
|
88c783b00b |
||
|
|
e4f9f69921 |
||
|
|
1ac5ea7cf9 |
||
|
|
ca5e8221ed |
||
|
|
21d80d36bf |
||
|
|
3812f077ec |
||
|
|
62007e4e0b |
||
|
|
1bf81a3b6a |
||
|
|
2d080204ac |
||
|
|
452732482b |
||
|
|
233447d8a8 | ||
|
|
08a7aa9c05 |
||
|
|
35dbee86e0 | ||
|
|
6d50210516 |
||
|
|
68be496db7 |
||
|
|
af02ba0f24 |
||
|
|
abb83e3ec6 |
||
|
|
f769ab4aa5 |
||
|
|
32fe1666ab |
||
|
|
311c884379 |
||
|
|
d1d30875b9 |
||
|
|
bf608cf145 |
||
|
|
7c59429ded |
||
|
|
eab622a628 |
||
|
|
b718283ae4 |
||
|
|
6dd00eb732 |
||
|
|
9cdb3c98fa |
||
|
|
db11dd504f |
||
|
|
511c1bfcc0 |
||
|
|
c787fab56e |
||
|
|
24e701a040 |
||
|
|
b91bdb92c1 |
||
|
|
7148d8e193 |
||
|
|
5488f02f8c |
||
|
|
dc9b996a66 |
||
|
|
be52775aa8 |
||
|
|
4bd49c4fdf | ||
|
|
cfc481c689 | ||
|
|
1c171d2fb9 |
||
|
|
3d81b4b37f | ||
|
|
ec7eddfe4b | ||
|
|
242beba95f | ||
|
|
488d0512f0 |
||
|
|
e467f55ab0 | ||
|
|
1fe929b411 | ||
|
|
74772f9794 |
||
|
|
6e331f7d90 |
||
|
|
e08971ead7 |
||
|
|
2f1ce88328 |
||
|
|
5f847201b8 |
||
|
|
f0478668b3 |
||
|
|
bbfd8b877a | ||
|
|
b772cff3ee |
||
|
|
403550714f |
||
|
|
bf2327a1d3 |
||
|
|
a586339c94 |
||
|
|
8d951537c9 |
||
|
|
0436ac1df9 |
||
|
|
3278330e17 |
||
|
|
6016043a51 |
||
|
|
1073310bfa |
||
|
|
3d33b85265 |
||
|
|
44e66119bf |
||
|
|
3ab8b9cc88 |
||
|
|
2cc1bec734 |
||
|
|
d268d19a82 |
||
|
|
ca90634b05 |
||
|
|
8b2ef681bd | ||
|
|
3ee5f065ea |
||
|
|
4122def194 |
||
|
|
12d2f5bffa |
||
|
|
bbf038341f |
||
|
|
56f66c6c02 |
||
|
|
b9bca121ba |
||
|
|
0f89560aa5 |
||
|
|
ff895a933f |
||
|
|
6654f6c97b |
||
|
|
20107290f0 |
||
|
|
1b25296cca |
||
|
|
dfb7d0aba8 | ||
|
|
2187a166bd |
||
|
|
e2849f58b7 |
||
|
|
2e70f6c1e1 |
||
|
|
512b57f683 |
||
|
|
42750af9a2 |
||
|
|
e084afbcd6 |
||
|
|
a147aed952 |
||
|
|
be9293685c |
||
|
|
4a693272d8 |
||
|
|
0a88fcf6f4 |
||
|
|
d91825412c |
||
|
|
2028cad2b5 | ||
|
|
e9411cba36 |
||
|
|
f7cfb0fa88 |
||
|
|
9731cba4f6 |
||
|
|
9522c032a7 |
||
|
|
1d4db617d6 |
||
|
|
859c3fb47c |
||
|
|
683ca61c65 |
||
|
|
df9b3bf914 |
||
|
|
2779ec2cd9 |
||
|
|
9592327230 |
||
|
|
019586d515 |
||
|
|
b46987fa3c |
||
|
|
3461ed3003 |
||
|
|
0ff000c01a |
||
|
|
5e69605d23 |
||
|
|
62ceca6ee5 |
||
|
|
f91c92eeec |
||
|
|
67f64e1401 |
||
|
|
57dddf7a45 |
||
|
|
9d532e4d67 |
||
|
|
5db7938fc4 | ||
|
|
16c52e717e |
||
|
|
784854ec47 |
||
|
|
564195d4ca |
||
|
|
2be89423b0 |
||
|
|
c3df71d8cb |
||
|
|
082c7d1ba0 | ||
|
|
f6607ea187 | ||
|
|
070dd00875 |
||
|
|
44ea9c344a |
||
|
|
c61b8ba08f |
||
|
|
00331e8aa1 |
||
|
|
0304a76f27 |
||
|
|
b38703a9c5 |
||
|
|
6bd2c43a59 |
||
|
|
f54572dc2e |
||
|
|
076ac96bf3 |
||
|
|
dfcf9feb71 |
||
|
|
63ec14a5dc |
||
|
|
636bf97913 |
||
|
|
5cfcd093f4 |
||
|
|
d46093ed8f |
||
|
|
48c049173d |
||
|
|
b451e70a89 |
||
|
|
e6f2349e35 |
||
|
|
93f254b6da |
||
|
|
d7ce31eb97 |
||
|
|
118ba7aa61 |
||
|
|
84ce6022d9 |
||
|
|
490d69bdb8 |
||
|
|
c7f9e023f9 |
||
|
|
912e10828a |
||
|
|
e49814635f |
||
|
|
001ab7b2d3 |
||
|
|
67fc71db83 |
||
|
|
aaf3cf1ecf |
||
|
|
659ff4b6d4 |
||
|
|
0f37b6e81e |
||
|
|
c436fdae5f |
||
|
|
b36ab8d8ba |
||
|
|
04d23466b9 |
||
|
|
8f77aac200 |
||
|
|
aa124513bc |
||
|
|
e5bb241a16 |
||
|
|
b0695baf1e |
||
|
|
c44d2c1636 |
||
|
|
036183b594 |
||
|
|
6064a6d915 |
||
|
|
a3437fbb59 |
||
|
|
e0c8294966 |
||
|
|
222bf352d4 |
||
|
|
b0e5e9fff6 |
||
|
|
12c299481a |
||
|
|
88660120e6 |
||
|
|
9ac624f2ce |
||
|
|
ae5870717e |
||
|
|
c2d3d3cebc |
||
|
|
b322218bb8 |
||
|
|
94276bce04 |
||
|
|
9b4535778a |
||
|
|
4dad028eda |
||
|
|
fa4321a773 |
||
|
|
c5bff9339f |
||
|
|
e1f1570314 |
||
|
|
a8d2af33ef |
||
|
|
4638a12f6d |
||
|
|
5e04e688a0 |
||
|
|
77064e304a |
||
|
|
f36b2c9606 |
||
|
|
4e5b12b84a |
||
|
|
a00d581ace |
||
|
|
4140fe83f0 |
||
|
|
f7a3b79dfe |
||
|
|
c7c608db81 |
||
|
|
8accf74b81 |
||
|
|
de02d36bbe |
||
|
|
cbed8fbbb0 |
||
|
|
c75ca2f36b |
||
|
|
1a12d13ae2 |
||
|
|
edbc3fb007 |
||
|
|
9f68d06816 |
||
|
|
18b51e4cce |
||
|
|
1c1c7b3a9d |
||
|
|
beaa869300 |
||
|
|
6eec60e228 |
||
|
|
e82a54000f |
||
|
|
02dff02526 |
||
|
|
35623e4d3a |
||
|
|
d5da94a36d |
||
|
|
077db1fed7 |
||
|
|
f18d7f6194 | ||
|
|
b72cb74359 |
||
|
|
a752b3dcd4 | ||
|
|
2e700bf40e |
||
|
|
765892922a |
||
|
|
0d8e4ccf48 |
||
|
|
6d72beeb45 |
||
|
|
94d30b87b7 |
||
|
|
a99f874db0 |
||
|
|
59987e1176 |
||
|
|
fe7a6e15dd | ||
|
|
51354da826 |
||
|
|
e8e5811913 |
||
|
|
8f841fc427 |
||
|
|
ebc3d61f8b |
||
|
|
5dcefb95b0 | ||
|
|
cfb9e92136 |
||
|
|
99135d633a |
||
|
|
2f3a44d1c6 |
||
|
|
96884caf1e |
||
|
|
51b9ac8a6d | ||
|
|
1052371074 | ||
|
|
ab4ebd32df | ||
|
|
ec6149d413 | ||
|
|
e7410f3ef1 | ||
|
|
9cd94e4e53 |
||
|
|
7b1d062989 |
||
|
|
df3f5f8eee |
||
|
|
70e9d632ae |
||
|
|
5278718807 |
||
|
|
dd0f0acf3a |
||
|
|
9974c40cd8 | ||
|
|
1183237ee3 |
||
|
|
da9ec3fbc1 |
||
|
|
d231155cff |
||
|
|
69d9e8ea33 |
||
|
|
4dcfd673eb |
||
|
|
d2eda376df |
||
|
|
d5bd6ead0d |
||
|
|
010b0a4579 |
||
|
|
d031c5a0ae |
||
|
|
6e53318f40 |
||
|
|
c33ec9a4b5 |
||
|
|
f18a358608 |
||
|
|
195ac775d8 |
||
|
|
0da58d3f37 |
||
|
|
8e325a0634 |
||
|
|
6445b32194 |
||
|
|
eb3eb4b3f7 |
||
|
|
cc7845d2c6 |
||
|
|
739d2a3d98 |
||
|
|
63683f9e89 |
||
|
|
5701de2dbe |
||
|
|
323568283a |
||
|
|
e413213769 |
||
|
|
c92e42c170 |
||
|
|
1671473497 |
||
|
|
70c441c5a3 |
||
|
|
643f435146 |
||
|
|
11174b9c73 |
||
|
|
3411b13336 |
||
|
|
d6422f2f24 |
||
|
|
bab655b3d2 |
||
|
|
523de95c37 |
||
|
|
b11a52c40e |
||
|
|
a324ff222b |
||
|
|
5e9e65bd1a |
||
|
|
4059579b94 |
||
|
|
6e87c09b4f |
||
|
|
08d373daf1 |
||
|
|
25a5c8ac7e |
||
|
|
f612e0e0d2 |
||
|
|
ad32047e13 |
||
|
|
7dc08e93b1 |
||
|
|
8a8e3d45bf |
||
|
|
505d406687 |
||
|
|
69a208a1ac |
||
|
|
6a75de3d1e |
||
|
|
5dd3c374f3 |
||
|
|
d148ec4acd |
||
|
|
e0e1e5d353 |
||
|
|
39d06a12be |
||
|
|
ec6317f759 |
||
|
|
e5223462b1 |
||
|
|
1fc8ee6fd9 |
||
|
|
71073724fc |
||
|
|
3dc5cce710 |
||
|
|
bd2071cff2 |
||
|
|
7a7e87ebd0 |
||
|
|
690a9d1b72 |
||
|
|
d3b4371f75 |
||
|
|
bc060bc12a |
||
|
|
65308c3840 |
||
|
|
f4449a7779 |
||
|
|
45c5b9fe38 |
||
|
|
1785764ebd |
||
|
|
846a2dbaf0 |
||
|
|
b92a01b2c4 |
||
|
|
3c31f3d6ac |
||
|
|
48a8ff8644 |
||
|
|
920dc0a0a2 |
||
|
|
11ca1bb0b0 |
||
|
|
d2774585a9 |
||
|
|
eb478f9938 |
||
|
|
8746713f15 |
||
|
|
badcd8c40d |
||
|
|
766cf8727e |
||
|
|
de8f23c37a |
||
|
|
878782a5a1 | ||
|
|
cedb8953f4 | ||
|
|
9e72635f2b | ||
|
|
0fd77b0b74 |
||
|
|
ec266c9d43 |
||
|
|
4f12d50586 |
||
|
|
3eecb01e3a |
||
|
|
28cbcc41f9 |
||
|
|
57a33085f1 |
||
|
|
9bbe5cac20 |
||
|
|
9de8d81d07 |
||
|
|
f44cec1d47 |
||
|
|
e5d4c4dba3 |
||
|
|
88384da901 |
||
|
|
6cce7d4576 |
||
|
|
28f8711646 |
||
|
|
dbdd57c35b |
||
|
|
76bac0b7de | ||
|
|
1b04357d63 |
||
|
|
c3003846e7 |
||
|
|
a8049a6ee0 |
||
|
|
d2804b491d |
||
|
|
d3768a8843 | ||
|
|
efc2d13b9c | ||
|
|
e12871fe45 |
||
|
|
2b3ee7eb48 |
||
|
|
fa8367872e |
||
|
|
8aa5a4819e |
||
|
|
106bba42ae |
||
|
|
874293bdd8 |
||
|
|
a22c208f5a |
||
|
|
276d1297ba |
||
|
|
ed3a283db4 |
||
|
|
ace4006253 |
||
|
|
6e49499e2a |
||
|
|
5b4404bd62 |
||
|
|
be3af528e0 |
||
|
|
7ba8965033 |
||
|
|
ffc5637237 |
||
|
|
79542927a7 |
||
|
|
b27c994651 | ||
|
|
c1bbc75824 | ||
|
|
474387d06c |
||
|
|
192b3baca2 |
||
|
|
2457852fb5 |
||
|
|
40b5a11035 |
||
|
|
0f9328f5d1 |
||
|
|
ba5d01a0d5 | ||
|
|
2f6da75d20 | ||
|
|
895877e10e | ||
|
|
cda72b3a37 |
||
|
|
215c6fe7d1 |
||
|
|
0d1b59de40 |
||
|
|
aaa2121cf5 |
||
|
|
2daf340399 |
||
|
|
e746e9f48a | ||
|
|
f5e6620845 | ||
|
|
e82f715355 |
||
|
|
5d4ac7837c |
||
|
|
978a11e1f3 |
||
|
|
fdb562c094 |
||
|
|
fb77998499 |
||
|
|
423b0eac33 |
||
|
|
25b87d092b |
||
|
|
0c9eba7014 |
||
|
|
645981be64 |
||
|
|
3417e6057b |
||
|
|
3f56f8c712 |
||
|
|
ca3ed3b213 |
||
|
|
5e48808ad2 | ||
|
|
f4edd681f8 | ||
|
|
6723886bbf |
||
|
|
b04f6954fe |
||
|
|
77b19dff54 | ||
|
|
7090ecce26 |
||
|
|
d6b2bddde2 |
||
|
|
5d29575411 |
||
|
|
78cf1587e7 |
||
|
|
6ba1fa0082 |
||
|
|
de16c25ac3 |
||
|
|
f6570bed80 |
||
|
|
d2879dd74a |
||
|
|
3229dac6f5 |
||
|
|
58ecdec067 |
||
|
|
24d4342ce5 |
||
|
|
3b5cf9e917 |
||
|
|
74807568a7 |
||
|
|
6344527f5a |
||
|
|
5995424d7f |
||
|
|
7332b637e9 |
||
|
|
b946f54da8 |
||
|
|
5bc7def2d9 |
||
|
|
9146f920be |
||
|
|
2862dd1e90 | ||
|
|
0c48143c17 | ||
|
|
a5a148dbb1 |
||
|
|
5013caecb3 |
||
|
|
c6947828c7 |
||
|
|
52daee159e |
||
|
|
ea87061e7e |
||
|
|
da5f6e564b |
||
|
|
194cb953ed |
||
|
|
7bac1ab782 |
||
|
|
eb00c45437 |
||
|
|
a4b09cb0c7 |
||
|
|
d56fe84d94 |
||
|
|
81938cec03 | ||
|
|
f476a0a32e |
||
|
|
67df2df608 |
||
|
|
45ec284c17 | ||
|
|
93bc3853a6 | ||
|
|
918638195b |
||
|
|
92090c12e3 |
||
|
|
d8e8b290b5 |
||
|
|
ba19fa6e77 |
||
|
|
59ec9ac744 |
||
|
|
1558e20fbb | ||
|
|
b8c3be2edb | ||
|
|
6309bb307b |
||
|
|
534eaa90ce |
||
|
|
8e3a275a0e |
||
|
|
b795eb1781 |
||
|
|
34ec688631 |
||
|
|
d7e2505ecf | ||
|
|
fa78a31f34 | ||
|
|
1eba404634 |
||
|
|
ad192014b6 |
||
|
|
edd6b9f53c |
||
|
|
6655ed3eab |
||
|
|
3d39d8f314 |
||
|
|
83eb72f0bb |
||
|
|
6b2725ceb2 |
||
|
|
71cdcc5264 |
||
|
|
175f12de6b |
||
|
|
f3bde7c8a3 |
||
|
|
b661ee1226 |
||
|
|
440f49ab81 |
||
|
|
fdfd351a5a | ||
|
|
a576b6dc93 | ||
|
|
6d1e474023 | ||
|
|
31bb1deaab |
||
|
|
d1360ba5fc | ||
|
|
2708e69290 |
||
|
|
7d9eb6df3b | ||
|
|
76f78a34b2 | ||
|
|
85624f2dfa |
||
|
|
c9787f8227 |
||
|
|
9467f79bbc |
||
|
|
8fa51c18b3 |
||
|
|
83fad70a4c |
||
|
|
004b6a2eab |
||
|
|
8343455db4 |
||
|
|
335808b8c0 |
||
|
|
9508568b8d |
||
|
|
85b66436cd |
||
|
|
9cdcc1b590 |
||
|
|
f8d1c5f559 |
||
|
|
a68b6eca4c |
||
|
|
97b323ca7d |
||
|
|
94058c6f00 |
||
|
|
cf38f83b5b |
||
|
|
f295878bc1 |
||
|
|
b0b0c3f47a |
||
|
|
d62f6ea697 |
||
|
|
85b12e9125 |
||
|
|
5f6a4aab69 |
||
|
|
a3f41a6060 |
||
|
|
611b69e7d6 |
||
|
|
676fb40338 |
||
|
|
88b5bff529 |
||
|
|
59535943d6 |
||
|
|
a04fe9e23a |
||
|
|
32b8731413 |
||
|
|
962d899f02 |
||
|
|
fad663c19e |
||
|
|
82ae1e8041 |
||
|
|
b7a89f4e1f | ||
|
|
396d6137cc | ||
|
|
16c1ad59cf | ||
|
|
b6e1e0795b | ||
|
|
09b73c5a24 | ||
|
|
2c043f4448 | ||
|
|
6a264599dd | ||
|
|
0485542f16 | ||
|
|
18e0f8ee2f | ||
|
|
afae02579c | ||
|
|
5dc727e4a2 | ||
|
|
1f77aeb717 | ||
|
|
00aa101ef0 | ||
|
|
d152d955a5 | ||
|
|
9cccf6f0c9 | ||
|
|
ac8b273357 | ||
|
|
23d91815aa | ||
|
|
3675dee8c4 | ||
|
|
cbad2b76b5 | ||
|
|
996414f5cd | ||
|
|
8e26224509 | ||
|
|
96e8de775d | ||
|
|
34b45a1136 | ||
|
|
e82b01bfa4 | ||
|
|
7ad8cab866 | ||
|
|
e58ca1c32c | ||
|
|
40b46b0f40 | ||
|
|
bbf81cc91d | ||
|
|
0a69b845db | ||
|
|
af1eee8c46 | ||
|
|
57003d4bca | ||
|
|
202ccb3449 |
||
|
|
be54fc9734 |
||
|
|
b91b567813 |
||
|
|
9420df27ea |
||
|
|
5e21cb6d51 |
||
|
|
036f215f57 |
||
|
|
1f7d28bdb0 |
||
|
|
7e54cfd69b | ||
|
|
d9de31b1d1 | ||
|
|
b13d3887b7 | ||
|
|
33f69b90cc | ||
|
|
94a7953213 | ||
|
|
2a573365de | ||
|
|
88f2ab35ca |
||
|
|
dfd47ca17d |
||
|
|
6d1f341856 |
||
|
|
48d1d1a91f | ||
|
|
d98dec9d43 |
||
|
|
82f6500316 |
||
|
|
ebc2feb1ea |
||
|
|
8e2b4ecc14 | ||
|
|
0ad63c847b | ||
|
|
21472faa36 | ||
|
|
ee8d5ae062 |
||
|
|
2eb40f75fe |
||
|
|
b4e5e8d480 |
||
|
|
0f22f02fd6 |
||
|
|
d63df74492 |
||
|
|
35466ab8e6 |
||
|
|
03f563c07b |
||
|
|
d15617cb60 |
||
|
|
53781f3d92 |
||
|
|
005143ec0d |
||
|
|
987a11fbe9 |
||
|
|
a2a74fa02f |
||
|
|
9af4402307 |
||
|
|
823b42f697 |
||
|
|
273ca40577 |
||
|
|
8d538de136 |
||
|
|
cfdc0638ba |
||
|
|
e24e1e135d |
||
|
|
24ed56aba8 |
||
|
|
8611fd3ca2 |
||
|
|
204d4aaa0d |
||
|
|
8d7ab9ff2c |
||
|
|
4d027491cd |
||
|
|
41eb1a2cce |
||
|
|
4d36a402df |
||
|
|
a95e940253 |
||
|
|
815e46d3a7 |
||
|
|
41e11055d7 |
||
|
|
1620a5136f |
||
|
|
b87b40c82a |
||
|
|
5cf8f9c12b |
||
|
|
9c7e4e25bf | ||
|
|
7dc10234da | ||
|
|
019008417a |
||
|
|
9cc58f459d |
||
|
|
1b66f8f056 |
||
|
|
d17dc589d8 |
||
|
|
09b261ac4e |
||
|
|
f21d892c98 |
||
|
|
9606934421 |
||
|
|
3d427682b5 | ||
|
|
523232daf5 |
||
|
|
b87bcecbbd |
||
|
|
d71a2e7d3e |
||
|
|
ab6615d741 | ||
|
|
eb21d820b2 |
||
|
|
189e882725 |
||
|
|
ece4487d0b |
||
|
|
f9068ed537 |
||
|
|
9c47f9ff2e |
||
|
|
6b8887a171 | ||
|
|
a90c42f699 |
||
|
|
4223c59e06 | ||
|
|
1985a3934f |
||
|
|
75b48bb44f |
||
|
|
2822d61e18 |
||
|
|
3ae1e52da9 |
||
|
|
38e101c0ec |
||
|
|
a151055e5c |
||
|
|
0db37bc144 |
||
|
|
1d5e54b248 |
||
|
|
ed49f6277a |
||
|
|
0bdd5f4ffa |
||
|
|
0d219a8b90 |
||
|
|
731ba58171 |
||
|
|
aa89247cb2 |
||
|
|
92d15bc170 |
||
|
|
185e967ad9 |
||
|
|
42024717fc |
||
|
|
03b6979d8f | ||
|
|
97c2d3f083 |
||
|
|
a3adf45f24 |
||
|
|
434dc3aba5 |
||
|
|
bfa988e2a3 |
||
|
|
02921cbf17 |
||
|
|
5327658ed3 |
||
|
|
ae41c3412c |
||
|
|
ad41ab1914 |
||
|
|
c5b374b500 |
||
|
|
a21a066a94 |
||
|
|
385c1467b0 |
||
|
|
f5e681ac7b |
||
|
|
2cf51294be |
||
|
|
1c2a2736fe |
||
|
|
c3a2cb7808 | ||
|
|
00230fbcfd |
||
|
|
375df83880 |
||
|
|
e4ae8fa148 |
||
|
|
a38e1a3450 |
||
|
|
bc24eca437 |
||
|
|
c6014b9841 |
||
|
|
7dc0233ff2 |
||
|
|
b5faf3bd60 |
||
|
|
2f729b10f3 |
||
|
|
49469fd31e |
||
|
|
a28389ca39 |
||
|
|
c7b7b040c7 |
||
|
|
95b6f4f9ae |
||
|
|
a99a6f995e |
||
|
|
d1832de6d8 |
||
|
|
62dd753f53 |
||
|
|
07537a97f6 |
||
|
|
47e4a63a3b |
||
|
|
98f36c9990 |
||
|
|
ffb47a6ce9 | ||
|
|
6fa5806915 | ||
|
|
c5817ecbc5 |
||
|
|
62f7b27df2 |
||
|
|
ac16198fc9 |
||
|
|
2654acd134 |
||
|
|
c17abab9fc |
||
|
|
ced2b9af10 |
||
|
|
8756b20d32 |
||
|
|
6967dcc015 |
||
|
|
bd8f4928d9 |
||
|
|
ddf3d202bb |
||
|
|
c4ce0d6e9d |
||
|
|
38d3337e01 |
||
|
|
7b97c83b63 |
||
|
|
1cf5603475 | ||
|
|
14ec9ecffa |
||
|
|
3841444bc5 |
||
|
|
5e0280cb75 |
||
|
|
45b2d9e5c7 |
||
|
|
fe52791aa1 |
||
|
|
d351eacbf1 |
||
|
|
e2b8464123 |
||
|
|
14ce9fcd13 |
||
|
|
04f4d71d39 |
||
|
|
1e8cb5df91 |
||
|
|
14dc4bd864 |
||
|
|
822570724d |
||
|
|
a7eaf464fe |
||
|
|
83115ae207 |
||
|
|
50204c7a0f |
||
|
|
ab283bb791 |
||
|
|
f94859d464 |
||
|
|
9863797719 |
||
|
|
421e1f0000 |
||
|
|
cd78c86a19 |
||
|
|
cc5078b835 |
||
|
|
535fd42803 |
||
|
|
31189b98f9 |
||
|
|
9364d47ce3 |
||
|
|
cd61e20dc3 |
||
|
|
d80baf8a96 |
||
|
|
6a360d7763 |
||
|
|
badc392a89 |
||
|
|
3fdb609a18 |
||
|
|
03dead111e |
||
|
|
8c68c8f5d4 |
||
|
|
b9f7ee1a6e |
||
|
|
76bf9d9d60 |
||
|
|
b2cbe35bce |
||
|
|
bf4dde5c6c |
||
|
|
4cf82fc081 |
||
|
|
176ded4343 |
||
|
|
c942fdaa46 |
||
|
|
d7d9ba4e84 |
||
|
|
7f24ebef0c |
||
|
|
9cf95337bb |
||
|
|
590e9740b4 |
||
|
|
a867277c42 |
||
|
|
13d449ad9c | ||
|
|
ec09fb8286 | ||
|
|
4c99634fe6 | ||
|
|
9a1d5e9cdd |
||
|
|
349129fcfa |
||
|
|
95a2b53dec |
||
|
|
ebd0566c0f |
||
|
|
cd80c99b30 |
||
|
|
7abb213f08 |
||
|
|
68f1328587 |
||
|
|
58b279033b |
||
|
|
d5866d04fb |
||
|
|
585a904acc |
||
|
|
5bd331170f |
||
|
|
2b92cdbf87 |
||
|
|
e29be7c19c |
||
|
|
d79795ff08 |
||
|
|
f2a10e0c83 |
||
|
|
e1c7106802 |
||
|
|
95e38baf61 |
||
|
|
bb0d53c9bd | ||
|
|
4c8d686f8f | ||
|
|
eb5c2ca120 | ||
|
|
d45feb9e8e |
||
|
|
f638f42383 |
||
|
|
e7b5618c89 |
||
|
|
c191e6359d |
||
|
|
395d836129 |
||
|
|
3bd35f3eca |
||
|
|
2cc245dd5e |
||
|
|
d3a2637eb8 | ||
|
|
30916a2df3 | ||
|
|
4cfaf4188a | ||
|
|
6f0b5ecde1 |
||
|
|
5806e7b60e |
||
|
|
c40762e962 |
||
|
|
5ee85b11c1 |
||
|
|
435ee2ed64 |
||
|
|
369445a20a |
||
|
|
fa784af2fd |
538 changed files with 43920 additions and 22655 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[alias]
|
||||
xtask = "run --profile xtask --target host-tuple -p xtask --"
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
# reformat with biomejs
|
||||
798da7111ab611c07ff7171574cd2ab9e8254515
|
||||
# reformat with biomejs 1.9.2
|
||||
d7d9ba4e84925794e60e4ba194f96c6932418d29
|
||||
|
|
|
|||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github: [anatawa12]
|
||||
custom: [https://booth.pm/ja/items/6448396]
|
||||
50
.github/actions/sign-windows/action.yml
vendored
Normal file
50
.github/actions/sign-windows/action.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: 'Sign in place'
|
||||
description: 'Signs exe in-place'
|
||||
inputs:
|
||||
signpath-api-token:
|
||||
description: SignPath REST API access token.
|
||||
required: true
|
||||
signing-policy-slug:
|
||||
description: SignPath signing policy slug
|
||||
version:
|
||||
required: true
|
||||
description: Version number of artifact we're signing
|
||||
|
||||
path:
|
||||
description: The path of artifact we'll sign
|
||||
required: true
|
||||
artifact-name:
|
||||
description: The name of artifact on github
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: upload unsigned artifact
|
||||
id: upload-unsigned-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
name: ${{ inputs.artifact-name }}
|
||||
|
||||
- name: Sign on signpath
|
||||
id: sign
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ inputs.signpath-api-token }}
|
||||
organization-id: 'c2d4dbf9-920f-4318-9017-7306e0fc7590'
|
||||
project-slug: 'vrc-get'
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug }}
|
||||
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
|
||||
parameters: |
|
||||
version: ${{ toJSON(inputs.version) }}
|
||||
|
||||
- name: Copy artifact
|
||||
env:
|
||||
DOWNLOADED_PATH: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
|
||||
REPLACE_TO: ${{ inputs.path }}
|
||||
shell: bash
|
||||
run:
|
||||
cp -f "$DOWNLOADED_PATH/$(basename "$REPLACE_TO")" "$REPLACE_TO"
|
||||
8
.github/copilot-instructions.md
vendored
Normal file
8
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
If you're writing code:
|
||||
- Please don't make localization for locales other than en / ja. I cannot review those locales.
|
||||
- Run cargo clippy for lints and cargo fmt for format before commit.
|
||||
- After completing the code and commit, please add a changelog entry. Please note that the numbers in the changelog file are pull request numbers, not issue numbers.
|
||||
- Please add it to the bottom of the change list.
|
||||
- Please use the proper section for each change. "Fix" should be used only for bug fixes. UX improvements typically belong under "Change", and new features typically under "Add". These are not strict rules, so use them flexibly.
|
||||
- You should use Conventional Commits (chore:, fix:, dev:, build:, docs:, style:, lint:, and others).
|
||||
- Please split commits for implementation and changelog updates.
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -8,6 +8,8 @@ updates:
|
|||
groups:
|
||||
tauri:
|
||||
patterns: [ 'tauri', 'tauri-build' ]
|
||||
clap:
|
||||
patterns: [ 'clap', 'clap_complete' ]
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 20
|
||||
|
|
@ -23,3 +25,9 @@ updates:
|
|||
patterns: [ "@radix-ui/*" ]
|
||||
react:
|
||||
patterns: [ "react", "react-dom", "@types/react", "@types/react-dom" ]
|
||||
tanstack-router:
|
||||
patterns: [ "@tanstack/*router*" ]
|
||||
tailwindcss:
|
||||
patterns: [ "tailwindcss", "@tailwindcss/*" ]
|
||||
i18next:
|
||||
patterns: [ "i18next", "react-i18next" ]
|
||||
|
|
|
|||
194
.github/scripts/localization-updates.js
vendored
194
.github/scripts/localization-updates.js
vendored
|
|
@ -48,11 +48,28 @@ module.exports = async ({github, context}) => {
|
|||
discussionNumber: 1443,
|
||||
replyId: 'DC_kwDOIza9ks4An6A8'
|
||||
},
|
||||
{
|
||||
id: 'ko',
|
||||
discussionNumber: 1823,
|
||||
replyId: 'DC_kwDOIza9ks4AswKE'
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} */
|
||||
const localeData = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
|
||||
const proceed = await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
|
||||
localeData.push({
|
||||
id: locale.id,
|
||||
discussionNumber: locale.discussionNumber,
|
||||
missingCount: proceed.missingCount,
|
||||
extraCount: proceed.extraCount,
|
||||
})
|
||||
}
|
||||
|
||||
// 894 is English Localization and Text Representation
|
||||
await updateRootLocale(github, owner, repo, 894, localeData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,9 +80,113 @@ module.exports = async ({github, context}) => {
|
|||
* @param number {number}
|
||||
* @param replyToId {string}
|
||||
* @param localeId {string}
|
||||
* @return {Promise<void>}
|
||||
* @return {Promise<{missingCount: number, extraCount: number}>}
|
||||
*/
|
||||
async function processOneLocale(github, owner, repo, number, replyToId, localeId) {
|
||||
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
|
||||
const enKeys = normalizeKeys(Object.keys(enJson.translation));
|
||||
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
|
||||
const transKeys = normalizeKeys(Object.keys(transJson.translation));
|
||||
|
||||
const {missingList: missingKeys, extraList: extraKeys} = missingAndExtras(enKeys, transKeys);
|
||||
|
||||
const newData = {
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
};
|
||||
|
||||
const newAutoPart = `**Missing Keys:**\n${listToMarkdown(missingKeys)}\n\n**Excess Keys:**\n${listToMarkdown(extraKeys)}\n`;
|
||||
|
||||
const {discussionId, previousJson: dataJson} = await updateComment(github, owner, repo, number, newAutoPart, newData);
|
||||
dataJson.missingKeys ??= [];
|
||||
dataJson.extraKeys ??= [];
|
||||
|
||||
// create comment if there are new missing / extra keys
|
||||
const {extraList: newlyAddedMissingKeys} = missingAndExtras(normalizeKeys(dataJson.missingKeys), missingKeys);
|
||||
const {extraList: newlyAddedExtraKeys} = missingAndExtras(normalizeKeys(dataJson.extraKeys), extraKeys);
|
||||
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
|
||||
const text = `
|
||||
There are new missing / excess keys in the translation. Please update the translation!
|
||||
|
||||
**New Missing Keys:**
|
||||
|
||||
${listToMarkdown(newlyAddedMissingKeys)}
|
||||
|
||||
**New Excess Keys:**
|
||||
|
||||
${listToMarkdown(newlyAddedExtraKeys)}
|
||||
`
|
||||
|
||||
await github.graphql(`
|
||||
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
|
||||
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
|
||||
comment {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {discussionId, replyToId, body: text});
|
||||
}
|
||||
|
||||
return {
|
||||
missingCount: missingKeys.length,
|
||||
extraCount: extraKeys.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the root locale configuration for a specified repository.
|
||||
*
|
||||
* @param {import('@octokit/rest').Octokit} github - The GitHub API client instance used to interact with the GitHub API.
|
||||
* @param {string} owner - The owner of the repository where the root locale is to be updated.
|
||||
* @param {string} repo - The name of the repository where the root locale is to be updated.
|
||||
* @param {number} number
|
||||
* @param {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} localeData - The locale data object containing the updated root locale configuration.
|
||||
* @return {Promise<void>} A promise that resolves to the API response for the update operation.
|
||||
*/
|
||||
async function updateRootLocale(github, owner, repo, number, localeData) {
|
||||
let table = "| locale | missing count | exceeding count | link |\n" +
|
||||
"| -- | -- | -- | -- |\n"
|
||||
for (let {missingCount, extraCount, id, discussionNumber} of localeData) {
|
||||
table += `| ${id} | ${missingCount} | ${extraCount} | [link](https://github.com/${owner}/${repo}/discussions/${discussionNumber}) |\n`;
|
||||
}
|
||||
|
||||
await updateComment(github, owner, repo, number, table, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param beforeList {T[]}
|
||||
* @param afterList {T[]}
|
||||
* @return {{missingList: T[], extraList: T[]}}
|
||||
*/
|
||||
function missingAndExtras(beforeList, afterList) {
|
||||
const missingList = beforeList.filter(key => !afterList.includes(key)).filter(key => !optionalKeys.includes(key));
|
||||
const extraList = afterList.filter(key => !beforeList.includes(key)).filter(key => !optionalKeys.includes(key));
|
||||
|
||||
return {missingList, extraList};
|
||||
}
|
||||
|
||||
function listToMarkdown(values) {
|
||||
return values.length === 0 ? 'nothing' : values.map(key => `- \`${key}\``).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param github {import('@octokit/rest').Octokit}
|
||||
* @param owner {string}
|
||||
* @param repo {string}
|
||||
* @param number {number}
|
||||
* @param content {string} the updated content
|
||||
* @param newData {object} data stored in the comment
|
||||
* @return {Promise<{previousJson: object, discussionId:string}>}
|
||||
*/
|
||||
async function updateComment(
|
||||
github,
|
||||
owner, repo, number,
|
||||
content,
|
||||
newData,
|
||||
) {
|
||||
/** @type {{data: {repository: {discussion: {body: string}}}}} */
|
||||
const result = await github.graphql(`
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
|
|
@ -93,42 +214,14 @@ async function processOneLocale(github, owner, repo, number, replyToId, localeId
|
|||
const postAutoPart = split1[1] ?? '';
|
||||
|
||||
const dataJsonLine = autoPart.split(/\r?\n/).find(l => l.startsWith(dataJsonLinePrefix));
|
||||
// the dataJson is for computing the difference and create new comment if there are changes
|
||||
const dataJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
|
||||
dataJson.missingKeys ??= [];
|
||||
dataJson.extraKeys ??= [];
|
||||
|
||||
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
|
||||
const enKeys = normalizeKeys(Object.keys(enJson.translation));
|
||||
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
|
||||
const transKeys = normalizeKeys(Object.keys(transJson.translation));
|
||||
|
||||
const missingKeys = enKeys.filter(key => !transKeys.includes(key)).filter(key => !optionalKeys.includes(key));
|
||||
const extraKeys = transKeys.filter(key => !enKeys.includes(key)).filter(key => !optionalKeys.includes(key));
|
||||
|
||||
const missingKeysStr = missingKeys.length === 0 ? 'nothing' : missingKeys.map(key => `- \`${key}\``).join('\n');
|
||||
const excessKeysStr = extraKeys.length === 0 ? 'nothing' : extraKeys.map(key => `- \`${key}\``).join('\n');
|
||||
|
||||
const newData = {
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
};
|
||||
|
||||
const newAutoPart = `
|
||||
**Missing Keys:**
|
||||
|
||||
${missingKeysStr}
|
||||
|
||||
**Excess Keys:**
|
||||
|
||||
${excessKeysStr}
|
||||
const previousJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
|
||||
|
||||
const newBody = `${manualPart}${autoPartStart}
|
||||
${content}
|
||||
<!-- data part
|
||||
${dataJsonLinePrefix}${JSON.stringify(newData)}
|
||||
-->
|
||||
`;
|
||||
|
||||
const newBody = `${manualPart}${autoPartStart}${newAutoPart}${autoPartEnd}${postAutoPart}`;
|
||||
${autoPartEnd}${postAutoPart}`;
|
||||
|
||||
await github.graphql(`
|
||||
mutation($discussionId: ID!, $body: String!) {
|
||||
|
|
@ -140,36 +233,9 @@ ${dataJsonLinePrefix}${JSON.stringify(newData)}
|
|||
}
|
||||
`, {discussionId, body: newBody});
|
||||
|
||||
// create comment if there are new missing / extra keys
|
||||
const oldMissingKeys = new Set(normalizeKeys(dataJson.missingKeys));
|
||||
const oldExtraKeys = new Set(normalizeKeys(dataJson.extraKeys));
|
||||
const newlyAddedMissingKeys = missingKeys.filter(key => !oldMissingKeys.has(key));
|
||||
const newlyAddedExtraKeys = extraKeys.filter(key => !oldExtraKeys.has(key));
|
||||
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
|
||||
const newMissingKeysStr = newlyAddedMissingKeys.length === 0 ? 'nothing' : newlyAddedMissingKeys.map(key => `- \`${key}\``).join('\n');
|
||||
const newExcessKeysStr = newlyAddedExtraKeys.length === 0 ? 'nothing' : newlyAddedExtraKeys.map(key => `- \`${key}\``).join('\n');
|
||||
|
||||
const text = `
|
||||
There are new missing / excess keys in the translation. Please update the translation!
|
||||
|
||||
**New Missing Keys:**
|
||||
|
||||
${newMissingKeysStr}
|
||||
|
||||
**New Excess Keys:**
|
||||
|
||||
${newExcessKeysStr}
|
||||
`
|
||||
|
||||
await github.graphql(`
|
||||
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
|
||||
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
|
||||
comment {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {discussionId, replyToId, body: text});
|
||||
return {
|
||||
previousJson,
|
||||
discussionId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
241
.github/workflows/ci-gui.yml
vendored
241
.github/workflows/ci-gui.yml
vendored
|
|
@ -4,7 +4,6 @@ on:
|
|||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
|
@ -16,7 +15,8 @@ jobs:
|
|||
matrix:
|
||||
include:
|
||||
- triple: x86_64-unknown-linux-gnu
|
||||
on: ubuntu-latest
|
||||
on: ubuntu-22.04
|
||||
bundles: appimage,appimage-updater
|
||||
setup: |
|
||||
sudo apt update && sudo apt install -y lld
|
||||
ld.lld --version
|
||||
|
|
@ -26,12 +26,14 @@ jobs:
|
|||
|
||||
- triple: x86_64-pc-windows-msvc
|
||||
on: windows-latest
|
||||
bundles: setup-exe,setup-exe-zip,exe-updater
|
||||
|
||||
- triple: universal-apple-darwin
|
||||
on: macos-14
|
||||
setup: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
bundles: app,dmg,app-updater
|
||||
triple:
|
||||
- x86_64-unknown-linux-gnu
|
||||
#- aarch64-unknown-linux-gnu
|
||||
|
|
@ -44,9 +46,25 @@ jobs:
|
|||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: samypr100/setup-dev-drive@v4
|
||||
with:
|
||||
drive-size: 12GB # github actions grantees 14 GB of disk space. we have few GB for action environment
|
||||
drive-path: "/dev_drive.vhdx"
|
||||
mount-path: ${{ github.workspace }}
|
||||
trusted-dev-drive: true
|
||||
- name: Preinitialize git repository
|
||||
shell: bash
|
||||
run: |
|
||||
# hopefully for non-cleaning environments, actions/checkout will tries to clean existing repository
|
||||
# if existing dir is not git repository nor for specified repository.
|
||||
# this step creates git directory to workaround the behavior
|
||||
git init
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- run: rustup update stable
|
||||
- name: Install cross-compilation tools
|
||||
uses: taiki-e/setup-cross-toolchain-action@v1
|
||||
if: ${{ matrix.triple != 'universal-apple-darwin' }}
|
||||
|
|
@ -55,11 +73,8 @@ jobs:
|
|||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ci-build-gui-${{ matrix.triple }}
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.2xx
|
||||
- name: Cache javascript essentials
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
|
|
@ -68,11 +83,6 @@ jobs:
|
|||
key: ${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-${{ hashFiles('vrc-get-gui/**/*.js', 'vrc-get-gui/**/*.jsx', 'vrc-get-gui/**/*.ts', 'vrc-get-gui/**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-
|
||||
- uses: taiki-e/install-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
tool: cargo-about
|
||||
|
||||
- name: Setup
|
||||
run: ${{ matrix.setup }}
|
||||
|
|
@ -92,20 +102,16 @@ jobs:
|
|||
cp vrc-get-gui/Tauri.toml vrc-get-gui/Tauri.toml.bak
|
||||
grep -v "remove if ci" < vrc-get-gui/Tauri.toml.bak > vrc-get-gui/Tauri.toml
|
||||
|
||||
- name: Enable Devtools Feature
|
||||
shell: bash
|
||||
run: |
|
||||
cargo add --package vrc-get-gui tauri --features devtools
|
||||
- name: Build ALCOM binary
|
||||
run:
|
||||
cargo xtask build-alcom --release --target ${{ matrix.triple }} --devtools ${{ (secrets.ACTIONS_STEP_DEBUG || vars.ACTIONS_STEP_DEBUG) == 'true' && '--verbose' || '' }}
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
with:
|
||||
projectPath: vrc-get-gui
|
||||
tauriScript: npm run tauri
|
||||
args: |
|
||||
--target ${{ matrix.triple }} -c '{"version":"${{ steps.version.outputs.version }}", "bundle":{"windows":{"certificateThumbprint": null}}}'
|
||||
- name: Build installer
|
||||
shell: bash
|
||||
run: cargo xtask bundle-alcom --release --target ${{ matrix.triple }} --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: Upload built binary
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.triple }}
|
||||
path: |
|
||||
|
|
@ -116,6 +122,195 @@ jobs:
|
|||
target/${{ matrix.triple }}/release/bundle/*/ALCOM*
|
||||
target/${{ matrix.triple }}/release/bundle/*/alcom*
|
||||
|
||||
build-rpm:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- install_rust: false
|
||||
- no_dist: false
|
||||
- mock-env: fedora-40-x86_64
|
||||
install_rust: true
|
||||
no_dist: true
|
||||
mock-env:
|
||||
- fedora-40-x86_64
|
||||
- fedora-rawhide-x86_64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 'fedora:latest'
|
||||
options: --privileged
|
||||
env:
|
||||
MOCK_ENV: ${{ matrix.mock-env }}
|
||||
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
|
||||
steps:
|
||||
- name: Install CI dependencies
|
||||
run: dnf install -y git tar curl
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
# https://github.com/actions/checkout/issues/1169
|
||||
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
|
||||
- name: install dependencies
|
||||
run: dnf install -y mock rpmbuild
|
||||
- name: prepare rpm build environment
|
||||
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||
- name: update version name in spec file
|
||||
run: |
|
||||
COMMIT_HASH="$(git rev-parse HEAD)"
|
||||
SHORT_HASH="$(git rev-parse --short HEAD)"
|
||||
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
|
||||
|
||||
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
|
||||
|
||||
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
|
||||
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
|
||||
rm vrc-get-gui/Cargo.toml.bak
|
||||
git add vrc-get-gui/Cargo.toml
|
||||
|
||||
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${PKG_VERSION//-/\~}"
|
||||
- name: build source rpm package
|
||||
run: |
|
||||
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
|
||||
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
|
||||
- name: build rpm package
|
||||
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
|
||||
- name: copy built binaries
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
|
||||
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
|
||||
|
||||
- name: Upload built binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rpm-${{ matrix.mock-env }}
|
||||
path: artifacts/*
|
||||
|
||||
build-deb:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- install_rust: false
|
||||
- install_nodejs: false
|
||||
- apt-components: main
|
||||
- apt-with-updates: false
|
||||
|
||||
# Old distributions have older tools than we need. Download tools in build process
|
||||
- pbuilder-distribution: bookworm
|
||||
install_rust: true
|
||||
install_nodejs: true
|
||||
- pbuilder-distribution: jammy
|
||||
install_rust: true
|
||||
install_nodejs: true
|
||||
|
||||
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
|
||||
- pbuilder-distribution: bookworm
|
||||
mirror: http://debian-archive.trafficmanager.net/debian/
|
||||
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
- pbuilder-distribution: sid
|
||||
mirror: http://debian-archive.trafficmanager.net/debian/
|
||||
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
# Ubuntu legacy release
|
||||
- pbuilder-distribution: jammy
|
||||
mirror: http://archive.ubuntu.com/ubuntu/
|
||||
apt-components: main universe
|
||||
apt-with-updates: true
|
||||
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
# For note,
|
||||
# bookworm: libc6@2.36
|
||||
# sid: libc6@2.39 as of 2026/06/14
|
||||
# jammy: libc6@2.35
|
||||
pbuilder-distribution:
|
||||
# debian distribution
|
||||
- bookworm
|
||||
- sid
|
||||
# ubuntu distribution
|
||||
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
|
||||
target-arch:
|
||||
- amd64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TARGET_ARCH: ${{ matrix.target-arch }}
|
||||
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
|
||||
PBUILDER_MIRROR: ${{ matrix.mirror }}
|
||||
PBUILDER_KEYRING: ${{ matrix.keyring }}
|
||||
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
|
||||
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
|
||||
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
|
||||
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
path: vrc-get
|
||||
submodules: recursive
|
||||
- name: install dependencies
|
||||
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
|
||||
- name: prepare deb build environment
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
cp -r vrc-get-gui/bundle/debian debian
|
||||
sudo pbuilder create \
|
||||
--architecture "$TARGET_ARCH" \
|
||||
--keyring "$PBUILDER_KEYRING" \
|
||||
--mirror "$PBUILDER_MIRROR" \
|
||||
--distribution "$PBUILDER_DISTRIBUTION" \
|
||||
--components "$PBUILDER_COMPONENTS" \
|
||||
--othermirror "$PBUILDER_OTHERMIRROR"
|
||||
- name: update changelog
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
COMMIT_HASH="$(git rev-parse HEAD)"
|
||||
SHORT_HASH="$(git rev-parse --short HEAD)"
|
||||
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
|
||||
|
||||
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
|
||||
|
||||
cp debian/changelog debian/changelog.bak
|
||||
cat - debian/changelog.bak <<CHANGELOG > debian/changelog
|
||||
alcom (${PKG_VERSION//-/\~}-1) experimental;
|
||||
|
||||
* Upgraded version to ${PKG_VERSION}
|
||||
|
||||
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
|
||||
|
||||
CHANGELOG
|
||||
rm debian/changelog.bak
|
||||
|
||||
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
|
||||
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
|
||||
rm vrc-get-gui/Cargo.toml.bak
|
||||
git add vrc-get-gui/Cargo.toml
|
||||
|
||||
echo cat debian/changelog
|
||||
cat debian/changelog
|
||||
dpkg-parsechangelog
|
||||
- name: build source deb package
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
git archive --format=tar $(git write-tree) | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
|
||||
dpkg-buildpackage -d -S
|
||||
- name: build deb package
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
|
||||
- name: copy built binaries
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp vrc-get/debian/changelog artifacts/
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}* artifacts/
|
||||
ls artifacts
|
||||
- name: Print information about built package
|
||||
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
|
||||
|
||||
- name: Upload built binary
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
|
||||
path: artifacts/*
|
||||
|
||||
conclude-gui:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
|
|
|
|||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -4,7 +4,6 @@ on:
|
|||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
|
@ -74,7 +73,7 @@ jobs:
|
|||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
|
@ -87,9 +86,6 @@ jobs:
|
|||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ci-build-${{ matrix.triple }}
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.2xx
|
||||
|
||||
- name: Setup
|
||||
run: ${{ matrix.setup }}
|
||||
|
|
@ -103,24 +99,24 @@ jobs:
|
|||
- name: Build
|
||||
run: cargo build --verbose --target ${{ matrix.triple }}
|
||||
- name: Upload built binary
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.triple }}
|
||||
path: |
|
||||
target/${{ matrix.triple }}/debug/vrc-get*
|
||||
target/${{ matrix.triple }}/debug/libvrc-get*
|
||||
- name: Build tests
|
||||
run: cargo build --tests --target ${{ matrix.triple }}
|
||||
run: cargo build --tests --target ${{ matrix.triple }} -p vrc-get -p vrc-get-vpm
|
||||
- name: Run tests
|
||||
if: ${{ matrix.run-test }}
|
||||
run: cargo test --verbose --target ${{ matrix.triple }}
|
||||
run: cargo test --verbose --target ${{ matrix.triple }} -p vrc-get -p vrc-get-vpm
|
||||
- name: Check binary is statically linked
|
||||
shell: bash
|
||||
if: ${{ matrix.static-linked }}
|
||||
env:
|
||||
RUSTFLAGS: ''
|
||||
run: |
|
||||
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
|
||||
unset CARGO_BUILD_TARGET
|
||||
cargo run -p build-check-static-link target/${{ matrix.triple }}/debug/vrc-get*
|
||||
cargo xtask check-static-link target/${{ matrix.triple }}/debug/vrc-get${WINDIR:+.exe}
|
||||
|
||||
conclude:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/labeler.yml
vendored
4
.github/workflows/labeler.yml
vendored
|
|
@ -9,11 +9,11 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
|
|
|
|||
30
.github/workflows/lint.yml
vendored
30
.github/workflows/lint.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
fmt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
|
@ -40,10 +40,7 @@ jobs:
|
|||
run:
|
||||
sudo apt update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.2xx
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
|
|
@ -53,13 +50,12 @@ jobs:
|
|||
target/
|
||||
key: lints-${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- uses: auguwu/clippy-action@1.4.0
|
||||
- uses: auguwu/clippy-action@1.5.0
|
||||
with:
|
||||
token: ${{secrets.GITHUB_TOKEN}}
|
||||
check-args: --all --all-targets --all-features
|
||||
check-args: --all --all-targets --all-features --exclude windows-installer-wrapper
|
||||
args: -Dclippy::todo -Dwarnings
|
||||
|
||||
|
||||
|
||||
biome:
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -67,7 +63,19 @@ jobs:
|
|||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: run Biome
|
||||
working-directory: vrc-get-gui
|
||||
run: npm run biome -- ci
|
||||
|
||||
typecheck:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: run Type Check
|
||||
working-directory: vrc-get-gui
|
||||
run: npm ci && npx tsc
|
||||
|
|
|
|||
6
.github/workflows/localization-updates.yml
vendored
6
.github/workflows/localization-updates.yml
vendored
|
|
@ -12,13 +12,13 @@ jobs:
|
|||
contents: read
|
||||
discussions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- run: npm i --omit=dev
|
||||
working-directory: .github/scripts
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const script = require('./.github/scripts/localization-updates.js')
|
||||
|
|
|
|||
378
.github/workflows/publish-gui.yml
vendored
378
.github/workflows/publish-gui.yml
vendored
|
|
@ -18,6 +18,9 @@ on:
|
|||
default: true
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: releasing
|
||||
|
||||
jobs:
|
||||
pre-build:
|
||||
name: Update version name
|
||||
|
|
@ -28,11 +31,12 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: anatawa12/something-releaser@v3
|
||||
- uses: snow-actions/git-config-user@v1.0.0
|
||||
- run: rustup update stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
|
@ -63,6 +67,21 @@ jobs:
|
|||
;;
|
||||
esac
|
||||
|
||||
GUI_VERSION="$(get-version -t gui)"
|
||||
# update debian package
|
||||
cp vrc-get-gui/bundle/debian/changelog vrc-get-gui/bundle/debian/changelog.bak
|
||||
cat - vrc-get-gui/bundle/debian/changelog.bak <<CHANGELOG > vrc-get-gui/bundle/debian/changelog
|
||||
alcom (${GUI_VERSION//-/\~}-1) stable;
|
||||
|
||||
* Upgraded version to ${GUI_VERSION}
|
||||
|
||||
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
|
||||
|
||||
CHANGELOG
|
||||
rm vrc-get-gui/bundle/debian/changelog.bak
|
||||
# update rpm package
|
||||
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${GUI_VERSION//-/\~}"
|
||||
|
||||
case "$GITHUB_REF_NAME" in
|
||||
master | master-* )
|
||||
echo "head is master or master-*"
|
||||
|
|
@ -77,7 +96,7 @@ jobs:
|
|||
;;
|
||||
esac
|
||||
|
||||
gh-export-variable GUI_VERSION "$(get-version -t gui)"
|
||||
gh-export-variable GUI_VERSION "${GUI_VERSION}"
|
||||
env:
|
||||
RELEASE_KIND_IN: ${{ inputs.release_kind }}
|
||||
DRY_RUN: ${{ inputs.dry-run }}
|
||||
|
|
@ -85,14 +104,11 @@ jobs:
|
|||
# region changelog
|
||||
- name: Create Changelog
|
||||
id: changelog
|
||||
# if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
uses: anatawa12/sh-actions/changelog/prepare-release@master
|
||||
with:
|
||||
path: CHANGELOG-gui.md
|
||||
version: ${{ env.GUI_VERSION }}
|
||||
# use CHANGELOG-gui.md for all releases including beta for now
|
||||
# there are several logics uses generated changelog, so remember to update them if you change this
|
||||
prerelease: false # ${{ env.PRERELEASE }}
|
||||
prerelease: ${{ env.PRERELEASE }}
|
||||
tag-prefix: gui-v
|
||||
prerelease-note-heading: |
|
||||
Version ${{ env.GUI_VERSION }}
|
||||
|
|
@ -101,41 +117,21 @@ jobs:
|
|||
Version ${{ env.GUI_VERSION }}
|
||||
---
|
||||
- name: Upload CHANGELOG.md
|
||||
# if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: CHANGELOG
|
||||
path: CHANGELOG.md
|
||||
- name: copy release note
|
||||
# if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
|
||||
- name: Upload release note
|
||||
# if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-note-for-release
|
||||
path: release-note.md
|
||||
- name: remove temp release note file
|
||||
# if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
run: rm release-note.md
|
||||
# endregion changelog
|
||||
|
||||
# region notes
|
||||
- name: Reset notes.txt
|
||||
if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
run: |
|
||||
cat <<SHELL > vrc-get-gui/notes.txt
|
||||
# This is notes text for the updater json, which will be shown in the updater dialog.
|
||||
# Lines starting with '#' will be ignored (as comments).
|
||||
SHELL
|
||||
- name: Reset notes-beta.txt
|
||||
run: |
|
||||
cat <<SHELL > vrc-get-gui/notes-beta.txt
|
||||
# This is notes text for the updater json, which will be shown in the updater dialog.
|
||||
# Lines starting with '#' will be ignored (as comments).
|
||||
SHELL
|
||||
# endregion
|
||||
|
||||
- name: Commit
|
||||
id: update
|
||||
run: |-
|
||||
|
|
@ -144,29 +140,6 @@ jobs:
|
|||
git branch releasing
|
||||
git push -f -u origin releasing
|
||||
|
||||
build-web:
|
||||
name: Build gui web
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre-build ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: 'releasing'
|
||||
submodules: recursive
|
||||
- uses: taiki-e/install-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
tool: cargo-about
|
||||
|
||||
- run: npm run build
|
||||
working-directory: vrc-get-gui
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vrc-get-gui-web
|
||||
path: vrc-get-gui/out
|
||||
|
||||
build-rust:
|
||||
name: Build rust
|
||||
environment:
|
||||
|
|
@ -177,57 +150,67 @@ jobs:
|
|||
include:
|
||||
# note: when you changed paths for tauri updater (which are files with .sig),
|
||||
# remember keep in sync with build-updater-json
|
||||
- triple: x86_64-unknown-linux-gnu
|
||||
on: ubuntu-latest
|
||||
- name: x86_64-linux-appimage
|
||||
triple: x86_64-unknown-linux-gnu
|
||||
on: ubuntu-22.04
|
||||
setup: |
|
||||
sudo apt update && sudo apt install -y lld
|
||||
ld.lld --version
|
||||
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
rustflags: "-C link-arg=-fuse-ld=lld"
|
||||
last-bundles: appimage,appimage-updater
|
||||
updater-bundle: bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz
|
||||
dist-path: |
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage:alcom-${GUI_VERSION}-x86_64.AppImage
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage:alcom-${GUI_VERSION}-x86_64.AppImage
|
||||
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage.tar.gz:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage.tar.gz.sig:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz.sig
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz
|
||||
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz.sig:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz.sig
|
||||
|
||||
- triple: x86_64-pc-windows-msvc
|
||||
on: windows-latest
|
||||
esigner: true
|
||||
- name: x86_64-windows-all
|
||||
triple: x86_64-pc-windows-msvc
|
||||
on: windows-2022
|
||||
last-bundles: setup-exe-zip,exe-updater
|
||||
updater-bundle: bundle/setup/alcom-updater.exe
|
||||
dist-path: |
|
||||
ALCOM.exe:ALCOM-${GUI_VERSION}-x86_64.exe
|
||||
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.exe:ALCOM-${GUI_VERSION}-x86_64-setup.exe
|
||||
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.nsis.zip:ALCOM-${GUI_VERSION}-x86_64-setup.nsis.zip
|
||||
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.nsis.zip.sig:ALCOM-${GUI_VERSION}-x86_64-setup.nsis.zip.sig
|
||||
bundle/setup/alcom-setup.exe:ALCOM-${GUI_VERSION}-x86_64-setup.exe
|
||||
bundle/setup/alcom-setup.exe.zip:ALCOM-${GUI_VERSION}-x86_64-setup.exe.zip
|
||||
bundle/setup/alcom-updater.exe:ALCOM-${GUI_VERSION}-x86_64-updater.exe
|
||||
bundle/setup/alcom-updater.exe.sig:ALCOM-${GUI_VERSION}-x86_64-updater.exe.sig
|
||||
|
||||
- triple: universal-apple-darwin
|
||||
- name: universal-macos-all
|
||||
triple: universal-apple-darwin
|
||||
on: macos-14
|
||||
setup: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
last-bundles: dmg,app-updater
|
||||
updater-bundle: bundle/macos/ALCOM.app.tar.gz
|
||||
dist-path: |
|
||||
bundle/dmg/ALCOM_${GUI_VERSION}_universal.dmg:ALCOM-${GUI_VERSION}-universal.dmg
|
||||
|
||||
bundle/macos/ALCOM.app.tar.gz:ALCOM-${GUI_VERSION}-universal.app.tar.gz
|
||||
bundle/macos/ALCOM.app.tar.gz.sig:ALCOM-${GUI_VERSION}-universal.app.tar.gz.sig
|
||||
|
||||
triple:
|
||||
- x86_64-unknown-linux-gnu
|
||||
name:
|
||||
- x86_64-linux-appimage
|
||||
#- aarch64-unknown-linux-musl
|
||||
- x86_64-pc-windows-msvc
|
||||
#- aarch64-pc-windows-msvc
|
||||
- universal-apple-darwin
|
||||
- x86_64-windows-all
|
||||
#- aarch64-windows-all
|
||||
- universal-macos-all
|
||||
|
||||
runs-on: ${{ matrix.on }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
|
||||
needs: [ pre-build, build-web ]
|
||||
needs: [ pre-build ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
submodules: recursive
|
||||
- run: rustup update stable
|
||||
- name: Install cross-compilation tools
|
||||
uses: taiki-e/setup-cross-toolchain-action@v1
|
||||
if: ${{ matrix.triple != 'universal-apple-darwin' }}
|
||||
|
|
@ -237,54 +220,72 @@ jobs:
|
|||
with:
|
||||
cache-targets: false # for release build, do not cache build artifacts
|
||||
key: release-gui # there are no elements about build result, so it's ok to share between all builds
|
||||
- uses: taiki-e/install-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
tool: cargo-about
|
||||
|
||||
- name: Setup
|
||||
run: ${{ matrix.setup }}
|
||||
|
||||
- uses: anatawa12/setup-eSigner-CKA@v1
|
||||
if: ${{ matrix.esigner }}
|
||||
with:
|
||||
mode: ${{ vars.WIN_ESIGNER_MODE }}
|
||||
username: ${{ secrets.WIN_ESIGNER_USERNAME }}
|
||||
password: ${{ secrets.WIN_ESIGNER_PASSWORD }}
|
||||
totp-secret: ${{ secrets.WIN_ESIGNER_TOTP_SECRET }}
|
||||
- name: Build ALCOM binary
|
||||
run: |
|
||||
cargo xtask build-alcom --target ${{ matrix.triple }} --release ${{ matrix.alcom-build-options }}
|
||||
|
||||
- name: Download Web Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vrc-get-gui-web
|
||||
path: vrc-get-gui/out
|
||||
- name: pre-sign Bundle ALCOM app (macOS)
|
||||
if: ${{ contains(matrix.name, 'macos') }}
|
||||
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles app
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Sign ALCOM app (macOS)
|
||||
if: ${{ contains(matrix.name, 'macos') }}
|
||||
env:
|
||||
# apple code signing
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: cargo xtask sign-alcom-app --target ${{ matrix.triple }}
|
||||
|
||||
# tauri updater signing
|
||||
- name: Sign ALCOM exe (Windows)
|
||||
if: ${{ contains(matrix.name, 'windows') }}
|
||||
uses: ./.github/actions/sign-windows
|
||||
with:
|
||||
artifact-name: alcom-exe-unsigned
|
||||
path: target/${{ matrix.triple }}/release/ALCOM.exe
|
||||
|
||||
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
|
||||
version: ${{ needs.pre-build.outputs.gui-version }}
|
||||
|
||||
- name: Bundle Setup exe (Windows)
|
||||
if: ${{ contains(matrix.name, 'windows') }}
|
||||
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles setup-exe
|
||||
|
||||
- name: Sign Setup exe (Windows)
|
||||
if: ${{ contains(matrix.name, 'windows') }}
|
||||
uses: ./.github/actions/sign-windows
|
||||
with:
|
||||
artifact-name: alcom-setup-exe-unsigned
|
||||
path: target/${{ matrix.triple }}/release/bundle/setup/alcom-setup.exe
|
||||
|
||||
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
|
||||
version: ${{ needs.pre-build.outputs.gui-version }}
|
||||
|
||||
- name: Bundle ALCOM (${{ matrix.last-bundles }})
|
||||
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles ${{ matrix.last-bundles }}
|
||||
|
||||
- name: Sign updater artifacts (All Platforms)
|
||||
shell: bash
|
||||
if: ${{ matrix.updater-bundle }}
|
||||
env:
|
||||
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
UPDATER_BUNDLE: ${{ matrix.updater-bundle }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# we have to use x86 version of signtool since eSignerCKA does not work with x64 version
|
||||
TAURI_WINDOWS_SIGNTOOL_PATH: C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x86\signtool.exe
|
||||
|
||||
with:
|
||||
projectPath: vrc-get-gui
|
||||
tauriScript: npm run tauri
|
||||
# disable beforeBuildCommand since we already build web
|
||||
args: |
|
||||
--target ${{ matrix.triple }} -c '{"build":{"beforeBuildCommand":null}}'
|
||||
run: |
|
||||
UPDATER_BUNDLE="${UPDATER_BUNDLE//\$\{GUI_VERSION\}/$GUI_VERSION}"
|
||||
cargo xtask sign-alcom-updater "target/${{ matrix.triple }}/release/${UPDATER_BUNDLE}"
|
||||
|
||||
- name: Move artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
shell: bash
|
||||
env:
|
||||
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
|
|
@ -302,9 +303,153 @@ jobs:
|
|||
mv "target/${{ matrix.triple }}/release/$src" "artifacts/$dst"
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: artifacts-${{ matrix.triple }}
|
||||
name: artifacts-${{ matrix.name }}
|
||||
path: artifacts/*
|
||||
|
||||
build-rpm:
|
||||
needs: [ pre-build ]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- install_rust: false
|
||||
- no_dist: false
|
||||
- mock-env: fedora-40-x86_64
|
||||
install_rust: true
|
||||
no_dist: true
|
||||
mock-env:
|
||||
- fedora-40-x86_64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 'fedora:latest'
|
||||
options: --privileged
|
||||
env:
|
||||
MOCK_ENV: ${{ matrix.mock-env }}
|
||||
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
|
||||
|
||||
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
steps:
|
||||
- name: Install CI dependencies
|
||||
run: dnf install -y git tar curl
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
submodules: recursive
|
||||
# https://github.com/actions/checkout/issues/1169
|
||||
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
|
||||
- name: install dependencies
|
||||
run: dnf install -y mock rpmbuild
|
||||
- name: prepare rpm build environment
|
||||
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||
- name: build source rpm package
|
||||
run: |
|
||||
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
|
||||
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
|
||||
- name: build rpm package
|
||||
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
|
||||
- name: copy built binaries
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
|
||||
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
|
||||
|
||||
- name: Upload built binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-rpm-${{ matrix.mock-env }}
|
||||
path: artifacts/*
|
||||
|
||||
build-deb:
|
||||
needs: [ pre-build ]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- install_rust: false
|
||||
- install_nodejs: false
|
||||
- apt-components: main
|
||||
- apt-with-updates: false
|
||||
|
||||
# Old distributions have older tools than we need. Download tools in build process
|
||||
- pbuilder-distribution: jammy
|
||||
install_rust: true
|
||||
install_nodejs: true
|
||||
|
||||
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
|
||||
- pbuilder-distribution: jammy
|
||||
mirror: http://archive.ubuntu.com/ubuntu/
|
||||
apt-components: main universe
|
||||
apt-with-updates: true
|
||||
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
# We build on jammy since it's the distribution with a) libwebkit2gtk-4.1 >= 2.41 and 2) oldest libc version required.
|
||||
# bookworm: libc6@2.36
|
||||
# sid: libc6@2.39 as of 2026/06/14
|
||||
# jammy: libc6@2.35
|
||||
pbuilder-distribution:
|
||||
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
|
||||
target-arch:
|
||||
- amd64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TARGET_ARCH: ${{ matrix.target-arch }}
|
||||
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
|
||||
PBUILDER_MIRROR: ${{ matrix.mirror }}
|
||||
PBUILDER_KEYRING: ${{ matrix.keyring }}
|
||||
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
|
||||
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
|
||||
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
|
||||
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
|
||||
|
||||
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
path: vrc-get
|
||||
submodules: recursive
|
||||
- name: install dependencies
|
||||
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
|
||||
- name: prepare deb build environment
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
( mkdir debian && cd debian && ln -s ../vrc-get-gui/bundle/debian/* . )
|
||||
sudo pbuilder create \
|
||||
--architecture "$TARGET_ARCH" \
|
||||
--keyring "$PBUILDER_KEYRING" \
|
||||
--mirror "$PBUILDER_MIRROR" \
|
||||
--distribution "$PBUILDER_DISTRIBUTION" \
|
||||
--components "$PBUILDER_COMPONENTS" \
|
||||
--othermirror "$PBUILDER_OTHERMIRROR"
|
||||
- name: build source deb package
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
git archive --format=tar HEAD | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
|
||||
dpkg-buildpackage -d -S
|
||||
- name: build deb package
|
||||
working-directory: vrc-get
|
||||
run: |
|
||||
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
|
||||
- name: copy built binaries
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb artifacts/
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.buildinfo artifacts/
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.changes artifacts/
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.debian.tar.xz artifacts/
|
||||
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.dsc artifacts/
|
||||
ls artifacts
|
||||
- name: Print information about built package
|
||||
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
|
||||
|
||||
- name: Upload built binary
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
|
||||
path: artifacts/*
|
||||
|
||||
build-updater-json:
|
||||
|
|
@ -312,11 +457,11 @@ jobs:
|
|||
needs: [ pre-build, build-rust ]
|
||||
steps:
|
||||
# use release
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: assets
|
||||
pattern: artifacts-*
|
||||
|
|
@ -325,15 +470,14 @@ jobs:
|
|||
- name: Run updater-json
|
||||
env:
|
||||
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
run: cargo run -p build-updater-json
|
||||
run: cargo xtask alcom-updater-json --version "$GUI_VERSION" --assets assets updater.json
|
||||
|
||||
- name: Upload updater-json
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updater.json
|
||||
path: |
|
||||
updater.json
|
||||
updater-beta.json
|
||||
|
||||
publish-to-github:
|
||||
name: Publish to GitHub
|
||||
|
|
@ -344,16 +488,16 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre-build, build-rust, build-updater-json ]
|
||||
needs: [ pre-build, build-rust, build-rpm, build-deb, build-updater-json ]
|
||||
env:
|
||||
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
|
|
@ -366,7 +510,7 @@ jobs:
|
|||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: assets
|
||||
pattern: artifacts-*
|
||||
|
|
@ -374,7 +518,7 @@ jobs:
|
|||
|
||||
- name: Download changelog
|
||||
# if: ${{ !needs.pre-build.outputs.prerelease }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-note-for-release
|
||||
path: changelog
|
||||
|
|
@ -427,7 +571,7 @@ jobs:
|
|||
- build-rust
|
||||
- publish-to-github
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
|
|
@ -444,14 +588,14 @@ jobs:
|
|||
env:
|
||||
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: vrc-get
|
||||
repositories: vrc-get.anatawa12.com
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'vrc-get/vrc-get.anatawa12.com'
|
||||
ref: 'master'
|
||||
|
|
@ -460,7 +604,7 @@ jobs:
|
|||
- uses: snow-actions/git-config-user@v1.0.0
|
||||
|
||||
- name: Download updater.json
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: updater.json
|
||||
path: .
|
||||
|
|
@ -474,7 +618,7 @@ jobs:
|
|||
mv updater.json public/api/gui/tauri-updater.json
|
||||
fi
|
||||
rm public/api/gui/tauri-updater-beta.json || true
|
||||
mv updater-beta.json public/api/gui/tauri-updater-beta.json
|
||||
mv updater.json public/api/gui/tauri-updater-beta.json
|
||||
|
||||
- name: Commit
|
||||
run: |-
|
||||
|
|
|
|||
173
.github/workflows/publish-litedb.yml
vendored
173
.github/workflows/publish-litedb.yml
vendored
|
|
@ -1,173 +0,0 @@
|
|||
name: Publish (litedb)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_kind:
|
||||
type: choice
|
||||
description: The type of release.
|
||||
default: prerelease
|
||||
required: true
|
||||
options:
|
||||
- prerelease
|
||||
- start-rc
|
||||
- stable
|
||||
|
||||
jobs:
|
||||
pre-build:
|
||||
name: Update version name
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
litedb-version: ${{ env.LITEDB_VERSION }}
|
||||
prerelease: ${{ steps.update-version.outputs.prerelease }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: anatawa12/something-releaser@v3
|
||||
- uses: snow-actions/git-config-user@v1.0.0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Update Version Name
|
||||
id: update-version
|
||||
run: |
|
||||
# set version name in properties file
|
||||
case "$RELEASE_KIND_IN" in
|
||||
"prerelease" )
|
||||
get-version -t litedb | version-next | set-version -t litedb
|
||||
gh-export-variable PRERELEASE true
|
||||
gh-set-output prerelease true
|
||||
;;
|
||||
"start-rc" )
|
||||
get-version -t litedb | version-set-channel - rc 0 | set-version -t litedb
|
||||
gh-export-variable PRERELEASE true
|
||||
gh-set-output prerelease true
|
||||
;;
|
||||
"stable" )
|
||||
get-version -t litedb | version-set-channel - stable | set-version -t litedb
|
||||
gh-export-variable PRERELEASE false
|
||||
gh-set-output prerelease '' # empty string for false
|
||||
;;
|
||||
* )
|
||||
echo "invalid release kind: $RELEASE_KIND_IN"
|
||||
exit 255
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$GITHUB_REF_NAME" in
|
||||
master | master-* )
|
||||
echo "head is master or master-*"
|
||||
;;
|
||||
* )
|
||||
echo "invalid release kind: $RELEASE_KIND_IN is not allowd for $GITHUB_REF_NAME"
|
||||
exit 255
|
||||
;;
|
||||
esac
|
||||
|
||||
gh-export-variable LITEDB_VERSION "$(get-version -t litedb)"
|
||||
env:
|
||||
RELEASE_KIND_IN: ${{ github.event.inputs.release_kind }}
|
||||
|
||||
# check for unexpected breaking ABI changes
|
||||
- name: Check semver
|
||||
uses: obi1kenobi/cargo-semver-checks-action@v2
|
||||
with:
|
||||
package: vrc-get-litedb
|
||||
|
||||
- name: Commit
|
||||
id: update
|
||||
run: |-
|
||||
# commit & tag
|
||||
git commit -am "litedb v$LITEDB_VERSION"
|
||||
git branch releasing
|
||||
git push -f -u origin releasing
|
||||
|
||||
publish-crates-io:
|
||||
name: Publish to crates.io
|
||||
environment:
|
||||
name: crates.io
|
||||
url: https://crates.io/crates/vrc-get
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre-build ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Publish CARGO
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
|
||||
run: cargo publish --package vrc-get-litedb
|
||||
|
||||
publish-to-github:
|
||||
name: Publish to GitHub
|
||||
environment:
|
||||
name: actions-github-app
|
||||
url: https://github.com/anatawa12/vrc-get/releases/litedb-v${{ needs.pre-build.outputs.litedb-version }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre-build, publish-crates-io ]
|
||||
env:
|
||||
LITEDB_VERSION: ${{ needs.pre-build.outputs.litedb-version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# tools
|
||||
- uses: anatawa12/something-releaser@v3
|
||||
- uses: snow-actions/git-config-user@v1.0.0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Push tag
|
||||
run: |-
|
||||
# set tag and publish current version
|
||||
git tag "litedb-v$LITEDB_VERSION"
|
||||
git push --tags
|
||||
# create master and push
|
||||
git switch -c master
|
||||
git fetch origin master --depth=1
|
||||
git log --all --graph
|
||||
git push -u origin master
|
||||
sleep 1
|
||||
|
||||
- name: prepare next release & push
|
||||
if: ${{ !needs.pre-build.outputs.prerelease }}
|
||||
run: |
|
||||
get-version -t litedb | version-next | version-set-channel - beta 0 | set-version -t litedb
|
||||
LITEDB_NEXT="$(get-version -t litedb | version-stable)"
|
||||
git commit -am "chore: prepare for next version: litedb $LITEDB_NEXT"
|
||||
git push
|
||||
|
||||
cleanup:
|
||||
name: Cleanup
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- pre-build
|
||||
- publish-crates-io
|
||||
- publish-to-github
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
- name: remove releasing branch
|
||||
run: git push --delete origin releasing
|
||||
65
.github/workflows/publish.yml
vendored
65
.github/workflows/publish.yml
vendored
|
|
@ -12,6 +12,14 @@ on:
|
|||
- prerelease
|
||||
- start-rc
|
||||
- stable
|
||||
dry-run:
|
||||
type: boolean
|
||||
description: Dry Run, If true, do not publish release to GitHub.
|
||||
default: true
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: releasing
|
||||
|
||||
jobs:
|
||||
pre-build:
|
||||
|
|
@ -24,7 +32,7 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: anatawa12/something-releaser@v3
|
||||
|
|
@ -63,12 +71,16 @@ jobs:
|
|||
esac
|
||||
|
||||
case "$GITHUB_REF_NAME" in
|
||||
master | master-* )
|
||||
echo "head is master or master-*"
|
||||
master | master-* | hotfix-* )
|
||||
echo "head is master, master-*, or hotfix-*"
|
||||
;;
|
||||
* )
|
||||
echo "invalid release kind: $RELEASE_KIND_IN is not allowd for $GITHUB_REF_NAME"
|
||||
exit 255
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "head is not master, but DRY_RUN is true"
|
||||
else
|
||||
echo "head is not master, but DRY_RUN is false"
|
||||
exit 255
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
@ -76,6 +88,7 @@ jobs:
|
|||
gh-export-variable VPM_VERSION "$(get-version -t vpm)"
|
||||
env:
|
||||
RELEASE_KIND_IN: ${{ github.event.inputs.release_kind }}
|
||||
DRY_RUN: ${{ inputs.dry-run }}
|
||||
|
||||
# region changelog
|
||||
- name: Create Changelog
|
||||
|
|
@ -94,7 +107,7 @@ jobs:
|
|||
---
|
||||
- name: Upload CHANGELOG.md
|
||||
if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: CHANGELOG
|
||||
path: CHANGELOG.md
|
||||
|
|
@ -103,7 +116,7 @@ jobs:
|
|||
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
|
||||
- name: Upload release note
|
||||
if: ${{ !steps.update-version.outputs.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-note-for-release
|
||||
path: release-note.md
|
||||
|
|
@ -164,7 +177,7 @@ jobs:
|
|||
|
||||
needs: [ pre-build ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
submodules: recursive
|
||||
|
|
@ -186,12 +199,13 @@ jobs:
|
|||
run: cargo build --target ${{ matrix.triple }} --release --verbose
|
||||
- name: Check binary is statically linked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ''
|
||||
run: |
|
||||
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
|
||||
unset CARGO_BUILD_TARGET
|
||||
cargo run -p build-check-static-link target/${{ matrix.triple }}/release/vrc-get*
|
||||
cargo xtask check-static-link target/${{ matrix.triple }}/release/vrc-get${WINDIR:+.exe}
|
||||
|
||||
- name: Move artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
shell: bash
|
||||
run: |-
|
||||
mkdir artifacts
|
||||
|
|
@ -201,20 +215,22 @@ jobs:
|
|||
done
|
||||
popd
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: artifacts-${{ matrix.triple }}
|
||||
path: artifacts/*
|
||||
|
||||
publish-crates-io:
|
||||
name: Publish to crates.io
|
||||
if: ${{ !inputs.dry-run }}
|
||||
environment:
|
||||
name: crates.io
|
||||
url: https://crates.io/crates/vrc-get
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre-build, build-rust ]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 1
|
||||
|
|
@ -232,6 +248,7 @@ jobs:
|
|||
|
||||
publish-to-github:
|
||||
name: Publish to GitHub
|
||||
if: ${{ !inputs.dry-run }}
|
||||
environment:
|
||||
name: actions-github-app
|
||||
url: https://github.com/anatawa12/vrc-get/releases/v${{ needs.pre-build.outputs.cli-version }}
|
||||
|
|
@ -243,12 +260,12 @@ jobs:
|
|||
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
|
||||
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
|
|
@ -261,7 +278,7 @@ jobs:
|
|||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: assets
|
||||
pattern: artifacts-*
|
||||
|
|
@ -269,22 +286,24 @@ jobs:
|
|||
|
||||
- name: Download changelog
|
||||
if: ${{ !needs.pre-build.outputs.prerelease }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-note-for-release
|
||||
path: changelog
|
||||
|
||||
- name: Push tag
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |-
|
||||
# set tag and publish current version
|
||||
git tag "v$CLI_VERSION"
|
||||
git tag "vpm-v$VPM_VERSION"
|
||||
git push --tags
|
||||
# create master and push
|
||||
git switch -c master
|
||||
git fetch origin master --depth=1
|
||||
git switch -c "$BRANCH"
|
||||
git fetch origin "$BRANCH" --depth=1
|
||||
git log --all --graph
|
||||
git push -u origin master
|
||||
git push -u origin "$BRANCH"
|
||||
sleep 1
|
||||
|
||||
- name: create release
|
||||
|
|
@ -324,7 +343,7 @@ jobs:
|
|||
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
|
||||
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: 'releasing'
|
||||
fetch-depth: 2
|
||||
|
|
@ -334,7 +353,7 @@ jobs:
|
|||
publish-to-homebrew:
|
||||
name: Publish to homebrew
|
||||
# vrc-get is on autobump list https://github.com/Homebrew/homebrew-core/blame/master/.github/autobump.txt
|
||||
if: false # ${{ !needs.pre-build.outputs.prerelease }}
|
||||
if: false # ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
|
||||
environment:
|
||||
name: homebrew-core
|
||||
url: https://github.com/homebrew/homebrew-core
|
||||
|
|
@ -348,7 +367,7 @@ jobs:
|
|||
|
||||
publish-to-winget:
|
||||
name: Publish to winget
|
||||
if: ${{ !needs.pre-build.outputs.prerelease }}
|
||||
if: ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
|
||||
needs: [ pre-build, publish-to-github ]
|
||||
|
||||
uses: vrc-get/vrc-get/.github/workflows/publish-cli-winget.yml@master
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vrc-get-litedb/dotnet/LiteDB"]
|
||||
path = vrc-get-litedb/dotnet/LiteDB
|
||||
url = https://github.com/anatawa12/LiteDB.git
|
||||
305
CHANGELOG-gui.md
305
CHANGELOG-gui.md
|
|
@ -8,34 +8,291 @@ The format is based on [Keep a Changelog].
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- System Information card to Settings Page `#1406`
|
||||
- Traditional Chinese translation `#1442`
|
||||
- Reinstall some selected packages `#1450`
|
||||
- Install and Upgrade packages at once `#1450`
|
||||
- Upgrade to the stable latest version even if some package has newer prerelease version `#1450`
|
||||
- Buttons to open settings, logs, and templates location `#1451`
|
||||
- Error page `#1457`
|
||||
- Ctrl + F on Log, Projects List, and Project page will focus search box on the page `#1485`
|
||||
- Implement project sorting by creation date `#2941`
|
||||
|
||||
### Changed
|
||||
- GitHub Releases for ALCOM is no longer prereleases
|
||||
- Moved log files to `<vpm-home>/vrc-get/gui-logs` `#1446`
|
||||
- Logs pages overhaul `#1456`
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork `#1402`
|
||||
|
||||
### Security
|
||||
|
||||
## [1.1.6] - 2026-06-02
|
||||
### Added
|
||||
- The package list can show hidden packages. [`#2731`](https://github.com/vrc-get/vrc-get/pull/2731)
|
||||
- Build-time option to disable auto updater [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759)
|
||||
- Please read README for new build instruction.
|
||||
- User repositories can now be reordered by drag and drop [`#2935`](https://github.com/vrc-get/vrc-get/pull/2935)
|
||||
|
||||
### Changed
|
||||
- The "Clear Selection" button in the package management screen is now red (destructive style) to distinguish it from the "Install Selected" button [`#2803`](https://github.com/vrc-get/vrc-get/pull/2803)
|
||||
- File filled with '\0' or whitespace will be treated as empty file [`#2710`](https://github.com/vrc-get/vrc-get/pull/2710)
|
||||
- This should prevent `syntax error loading settings.json: expected value at line 1 column 1` if settings.json is broken
|
||||
- We also added a backup file to recover from settings.json corruption [`#2933`](https://github.com/vrc-get/vrc-get/pull/2933)
|
||||
- Completely changed how do we build ALCOM and how do we self-update ALCOM [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759) [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828) [`#2881`](https://github.com/vrc-get/vrc-get/pull/2881) [`#2882`](https://github.com/vrc-get/vrc-get/pull/2882) [`#2885`](https://github.com/vrc-get/vrc-get/pull/2885)
|
||||
- This fixes few problems relates to auto update
|
||||
- Please read README for new build instruction.
|
||||
- Improved backup speed by parallelizing the process [`#2746`](https://github.com/vrc-get/vrc-get/pull/2746)
|
||||
- Along with this change, the default compression level has been changed to `zip-fast`
|
||||
- We added dialog on enabling "Show Prerelease Packages" [`#2795`](https://github.com/vrc-get/vrc-get/pull/2795)
|
||||
- I hope this prevents users unexpectedly adding prerelease packages
|
||||
- Path for unitypackage on Template Editor now can be reselected [`#2635`](https://github.com/vrc-get/vrc-get/pull/2635)
|
||||
- ALCOM now refuses launching project if project is on noexec mount points [`#2814`](https://github.com/vrc-get/vrc-get/pull/2814)
|
||||
- This would cause problems with several native plugins
|
||||
- Already-added packages are now excluded from the package name suggestions in the Template Editor [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
|
||||
- Extended some timeouts to 1 minute [`#2826`](https://github.com/vrc-get/vrc-get/pull/2826)
|
||||
- Prevents timeouts in slow DNS environments
|
||||
- Improved robustness for package installation errors [`#2844`](https://github.com/vrc-get/vrc-get/pull/2844)
|
||||
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
|
||||
- Backslashes in path in zip file are now treated as path separator on unix [`#2845`](https://github.com/vrc-get/vrc-get/pull/2845)
|
||||
- This fixes problem with Gesture Manager 3.9.7
|
||||
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored [`#2930`](https://github.com/vrc-get/vrc-get/pull/2930)
|
||||
- They are formerly rejected as invalid url
|
||||
|
||||
### Fixed
|
||||
- Fixed an issue where the progress bar flickered and did not display correct progress in environments using WebKit as the renderer. [`#2641`](https://github.com/vrc-get/vrc-get/pull/2641)
|
||||
- Fails to import UnityPackages with files in `Packages` directory [`#2679`](https://github.com/vrc-get/vrc-get/pull/2679)
|
||||
- null as vpmDependencies value is not allowed [`#2709`](https://github.com/vrc-get/vrc-get/pull/2709)
|
||||
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
|
||||
- ALCOM cannot detect per-user flatpak installation of unity hub [`#2812`](https://github.com/vrc-get/vrc-get/pull/2812)
|
||||
- Unabled to import some untypackages [`#2821`](https://github.com/vrc-get/vrc-get/pull/2821)
|
||||
- It's hard to say but some older unitypackages ware unsupported.
|
||||
- Panic when resolving projects where dependency packages depend on newer versions of locked packages [`#2822`](https://github.com/vrc-get/vrc-get/pull/2822)
|
||||
- Missing glibc and libgcc_s dependency notation in .deb / .rpm distributon [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
|
||||
- Unclear error message for invalid version name or version range [`#2842`](https://github.com/vrc-get/vrc-get/pull/2842)
|
||||
- Default file names in save dialogs now include the appropriate file extension [`#2846`](https://github.com/vrc-get/vrc-get/pull/2846)
|
||||
- Template export now defaults to `{template name}.alcomtemplate`
|
||||
- Repository list export now defaults to `repositories.txt`
|
||||
- Uninformative `[object Object]` appearing as an error message [`#2848`](https://github.com/vrc-get/vrc-get/pull/2848)
|
||||
- New Unity Hub loading method may not load manually added Unity Editors [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
|
||||
- New Unity Hub loading method does load unity hub configuration on Linux [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
|
||||
- Too many open files when copying project `#2867
|
||||
- Added workaround for VRCDefaultWorldScene generation issue in SDK 3.10.2 or later [`#2916`](https://github.com/vrc-get/vrc-get/pull/2916)
|
||||
- See [this][default-scene-canny] canny for bug in VRCSDK and issue [#2913][issue-2913] for our decision.
|
||||
|
||||
### Security
|
||||
- Package hash checks are now enforced when installing packages [`#2849`](https://github.com/vrc-get/vrc-get/pull/2849)
|
||||
- It has been about two years since the error message for package hash mismatches was introduced.
|
||||
- It is now enforced for security.
|
||||
|
||||
[default-scene-canny]: https://feedback.vrchat.com/sdk-bug-reports/p/3102-3103-vrcscenetemplateinitializer-does-not-create-sample-scene-if-udon-prepr
|
||||
[issue-2913]: https://github.com/vrc-get/vrc-get/issues/2913
|
||||
|
||||
## [1.1.5] - 2025-11-16
|
||||
- Fix package version selector dropdown exceeding window height [`#2589`](https://github.com/vrc-get/vrc-get/pull/2589)
|
||||
- The dropdown list now has a maximum height of 50% of the viewport or 24rem, whichever is smaller
|
||||
- This prevents the version selector from overflowing the window on small screens
|
||||
- Fix muted-foreground color [`#2516`](https://github.com/vrc-get/vrc-get/pull/2516) [`#2517`](https://github.com/vrc-get/vrc-get/pull/2517)
|
||||
- Remove `DialogDescription` not in `DialogHeader` to fix text color
|
||||
- Fix 'Detected Loop' panic with valid database file [`#2607`](https://github.com/vrc-get/vrc-get/pull/2607)
|
||||
|
||||
## [1.1.4] - 2025-09-02
|
||||
### Added
|
||||
- Add compact gui option [`#2436`](https://github.com/vrc-get/vrc-get/pull/2436) [`#2450`](https://github.com/vrc-get/vrc-get/pull/2450) [`#2470`](https://github.com/vrc-get/vrc-get/pull/2470)
|
||||
|
||||
### Changed
|
||||
- Improved saving interacting with setting files [`#2485`](https://github.com/vrc-get/vrc-get/pull/2485)
|
||||
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
|
||||
- This should reduce losing settings after crashing ALCOM or PC.
|
||||
|
||||
### Fixed
|
||||
- Specifying a single unityversion doesn't work properly in alcomtemplate [`#2452`](https://github.com/vrc-get/vrc-get/pull/2452)
|
||||
- For example, if you'd like to specify `2022.3.22f1`, you need to set `2022.3.22`, not `2022.3.22f1`
|
||||
- You can now see correct validation and suggestions for this.
|
||||
- Home/End and Up/Down keys now consistently control cursor position in autocomplete fields [`#2466`](https://github.com/vrc-get/vrc-get/pull/2466)
|
||||
- Home/End keys now always move the text cursor regardless of autocomplete state
|
||||
- Up/Down keys move the text cursor when suggestions are not visible, and navigate suggestions when they are visible
|
||||
- Previously, these keys would sometimes be captured for suggestion navigation when autocomplete was open
|
||||
|
||||
## [1.1.3] - 2025-07-28
|
||||
### Added
|
||||
- Add support for `keywords` UPM manifest field [`#2375`](https://github.com/vrc-get/vrc-get/pull/2375)
|
||||
- You now can specifiy search keywords for package with `keywords` UPM manifest field
|
||||
- Favorites for templates [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
|
||||
- It's much easier to select project templates you likely to use.
|
||||
|
||||
### Changed
|
||||
- Improved the Template Editor with AutoComplete [`#2371`](https://github.com/vrc-get/vrc-get/pull/2371)
|
||||
- You no longer need to remember the package name (id) and version associated with the package.
|
||||
- You now can search package by display name, name (id), aliases to enter package name, and ALCOM shows common version range for you.
|
||||
- Updated project settings of templates to include Item layer [`#2373`](https://github.com/vrc-get/vrc-get/pull/2373)
|
||||
- You should no longer need to update layers and collision matrix before uploading world
|
||||
- Improved behavior about `settings.json` to `vcc.litedb` migration [`#2327`](https://github.com/vrc-get/vrc-get/pull/2327)
|
||||
- See [`vrchat-community/creator-companion#492`](https://github.com/vrchat-community/creator-companion/issues/492) and the PR for details
|
||||
- Last used template is now preserved [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
|
||||
- When you generally create project with custom template, you no longer need to change template every time.
|
||||
|
||||
### Fixed
|
||||
- Packages are not deselected after installing packages [`#2372`](https://github.com/vrc-get/vrc-get/pull/2372)
|
||||
|
||||
## [1.1.2] - 2025-06-30
|
||||
### Fixed
|
||||
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
|
||||
- Frontend error on package list update [`#2341`](https://github.com/vrc-get/vrc-get/pull/2341)
|
||||
|
||||
## [1.1.1] - 2025-06-21
|
||||
### Fixed
|
||||
- Unity can be duplicated [`#2321`](https://github.com/vrc-get/vrc-get/pull/2321)
|
||||
- Crash on creating a new project on Windows [`#2326`](https://github.com/vrc-get/vrc-get/pull/2326)
|
||||
|
||||
## [1.1.0] - 2025-06-19
|
||||
### Added
|
||||
- Support for Projects with Unity 2018 or older [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
|
||||
- Projects with Unity 2018 cannot be added before, but since this version you can add to your project list.
|
||||
- Unity 2017 or older doesn't have Unity Package Manager, the base system for VPM,
|
||||
so you cannot manage packages for projects with older unity.
|
||||
You can only launch Unity to open the project.
|
||||
- Projects with Unity 4 or older are still not supported, but I hope no one want to use such a vintage Unity with ALCOM.
|
||||
- New Project Template System [`#2105`](https://github.com/vrc-get/vrc-get/pull/2105) [`#2114`](https://github.com/vrc-get/vrc-get/pull/2114) [`#2125`](https://github.com/vrc-get/vrc-get/pull/2125) [`#2129`](https://github.com/vrc-get/vrc-get/pull/2129) [`#2204`](https://github.com/vrc-get/vrc-get/pull/2204) [`#2259`](https://github.com/vrc-get/vrc-get/pull/2259) [`#2260`](https://github.com/vrc-get/vrc-get/pull/2260) [`#2261`](https://github.com/vrc-get/vrc-get/pull/2261) [`#2275`](https://github.com/vrc-get/vrc-get/pull/2275) [`#2276`](https://github.com/vrc-get/vrc-get/pull/2276)
|
||||
- You now can create Project Templates in ALCOM.
|
||||
- The new form of template can install multiple VPM packages at once, and you also can import unitypackages.
|
||||
- You now can create blank project along with this system change.
|
||||
- Warning on upgrading major version or installing incompatible versions [`#2159`](https://github.com/vrc-get/vrc-get/pull/2159) [`#2313`](https://github.com/vrc-get/vrc-get/pull/2313)
|
||||
- When you're upgrading package versions majorly, you'll see the warning message about breaking changes.
|
||||
- I hope this should reduce problems with unexpectedly upgrading packages majorly.
|
||||
- In addition, we added more messages when you're installing packages with some compatibility concerns.
|
||||
- The previous version only has those messages at the bottom of the window, so you may not notice the message.
|
||||
- Not only that, you now can see the package is upgraded, reinstalled, downgraded, or newly installed.
|
||||
- Menu option to copy a project [`#2168`](https://github.com/vrc-get/vrc-get/pull/2168) [`#2219`](https://github.com/vrc-get/vrc-get/pull/2219) [`#2225`](https://github.com/vrc-get/vrc-get/pull/2225)
|
||||
- Simple enough, you can copy a project.
|
||||
- Remember recent project locations [`#2182`](https://github.com/vrc-get/vrc-get/pull/2182)
|
||||
- ALCOM now remembers a few multiple recent locations for project creation, and you can select from recent locations
|
||||
- Support for flatpak installation of unity hub [`#1586`](https://github.com/vrc-get/vrc-get/pull/1586)
|
||||
- ALCOM now detects flatpak installation of unity hub automatically
|
||||
- Projects page Grid View [`#2245`](https://github.com/vrc-get/vrc-get/pull/2245) [`#2257`](https://github.com/vrc-get/vrc-get/pull/2257)
|
||||
|
||||
### Changed
|
||||
- Changed how we read VCC's project information [`#1997`](https://github.com/vrc-get/vrc-get/pull/1997) [`#2036`](https://github.com/vrc-get/vrc-get/pull/2036) [`#2041`](https://github.com/vrc-get/vrc-get/pull/2041)
|
||||
- Along with this, building this project no longer needs dotnet SDK to build.
|
||||
- Migrated the project to Rust 2024 [`#1956`](https://github.com/vrc-get/vrc-get/pull/1956)
|
||||
- This is internal changes should not cause behavior changes
|
||||
- This would require Rust 1.85 for building this project
|
||||
- Removed `cargo-about` from build-time dependency [`#1961`](https://github.com/vrc-get/vrc-get/pull/1961)
|
||||
- This is internal changes should not cause behavior changes
|
||||
- I listed here since this may need update on package metadata of some package managers
|
||||
- The method to retrieve the list of Unity from Unity Hub [`#1808`](https://github.com/vrc-get/vrc-get/pull/1808) [`#1971`](https://github.com/vrc-get/vrc-get/pull/1971)
|
||||
- Since this version, ALCOM reads UnityHub's configuration files to get list of Unity installed to the machine.
|
||||
- Before this version, ALCOM called headless Unity Hub in the background.
|
||||
- New method might have some compatibility problem, especially with some sandbox system.
|
||||
- Please report us if you find some problem with the new system.
|
||||
- Enhance os info for windows [`#1968`](https://github.com/vrc-get/vrc-get/pull/1968)
|
||||
- You now can select multiple folders at once to adding project [`#2018`](https://github.com/vrc-get/vrc-get/pull/2018)
|
||||
- I didn't know official VCC had such a feature. Sorry for lack of feature!
|
||||
- You now can toggle "Show Prerelease Packages" from Manage Project page [`#2020`](https://github.com/vrc-get/vrc-get/pull/2020)
|
||||
- You can toggle "Show Prerelease Packages" from Select Packages dropdown
|
||||
- The requirements for unity project [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
|
||||
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
|
||||
- Improves launching unity behavior [`#2124`](https://github.com/vrc-get/vrc-get/pull/2124)
|
||||
- On linux, ALCOM will now read exit code, therefore, Unity no longer remains as a defunct process.
|
||||
- On macOS, we now launch Unity as a distinct / individual process, therefore several macOS subsystems should treat Unity as Unity instead of Unity as a part of ALCOM.
|
||||
- Downgraded glibc requirements for linux images [`#2160`](https://github.com/vrc-get/vrc-get/pull/2160)
|
||||
- This release will be built on ubuntu 22.04 so glibc 2.35 is new requirements
|
||||
- If you want to use on platforms with older glibc, build yourself or pull request to build on older environments.
|
||||
- Loading projects / repositories is now asynchronously [`#2169`](https://github.com/vrc-get/vrc-get/pull/2169)
|
||||
- You should be able to open a project / install packages much quickly than before!
|
||||
- The reload button will keep rotating while loading asynchronously
|
||||
- Open changelog, documentation, and reinstall single package from package list [`#2184`](https://github.com/vrc-get/vrc-get/pull/2184) [`#2208`](https://github.com/vrc-get/vrc-get/pull/2208) [`#2298`](https://github.com/vrc-get/vrc-get/pull/2298)
|
||||
- You can open the changelog and documentation from `...` button at the right of package list
|
||||
- Option to exclude VPM Packages from backups [`#2185`](https://github.com/vrc-get/vrc-get/pull/2185)
|
||||
- You can exclude VPM Packages from backups to reduce size of backup a little
|
||||
- However, if the package author ignored the recommendation from VRChat and us, and removed package from their repository, you may need to install another version when restoring the backup.
|
||||
- Since many of the repository maintainers have removed many packages in their repository and VPM Packages are relatively small, this feature is disabled by default. You can enable this on the settings page.
|
||||
- Show the range of requested package in missing dependencies dialog [`#2187`](https://github.com/vrc-get/vrc-get/pull/2187)
|
||||
- `LastSceneManagerSetup.txt` in `Library` directory will be included in backups or copying project [`#2205`](https://github.com/vrc-get/vrc-get/pull/2205)
|
||||
- With this file preserved, you can expect to open the last opened scene file when you opened projects restored from backups.
|
||||
- Improved behavior when the project directory is not a valid project but the directory exists [`#2225`](https://github.com/vrc-get/vrc-get/pull/2225)
|
||||
- Open Unity now will update `Last Modified` of a project. [`#2228`](https://github.com/vrc-get/vrc-get/pull/2228)
|
||||
|
||||
### Fixed
|
||||
- Layout shift on select package [`#2045`](https://github.com/vrc-get/vrc-get/pull/2045)
|
||||
- Unable to change the unity version from "unknown" if ProjectVersion.txt does not exists [`#2183`](https://github.com/vrc-get/vrc-get/pull/2183)
|
||||
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` [`#2209`](https://github.com/vrc-get/vrc-get/pull/2209)
|
||||
- Too Many Open Files on backing up some projects [`#2262`](https://github.com/vrc-get/vrc-get/pull/2262)
|
||||
|
||||
## [1.0.1] - 2025-02-05
|
||||
### Added
|
||||
- Add Korean localization [`#1822`](https://github.com/vrc-get/vrc-get/pull/1822)
|
||||
|
||||
### Fixed
|
||||
- Fixed toast message when adding repositories [`#1815`](https://github.com/vrc-get/vrc-get/pull/1815)
|
||||
- Improved several linux desktop support [`#1821`](https://github.com/vrc-get/vrc-get/pull/1821)
|
||||
- Backup file used UTC time instead of Local time [`#1862`](https://github.com/vrc-get/vrc-get/pull/1862)
|
||||
- Worlds templates doesn't have proper input axis settings [`#1902`](https://github.com/vrc-get/vrc-get/pull/1902)
|
||||
|
||||
## [1.0.0] - 2025-01-01
|
||||
### Fixed
|
||||
- Link to unity hub is hardcoded to Japanese [`#1810`](https://github.com/vrc-get/vrc-get/pull/1810)
|
||||
- Fixed link to respect currently configured language
|
||||
- Fixed Logs page autoscroll not enable on start [`#1811`](https://github.com/vrc-get/vrc-get/pull/1811)
|
||||
- Fixed failed to load project list with invalid unity version stored [`#1813`](https://github.com/vrc-get/vrc-get/pull/1813)
|
||||
|
||||
## [0.1.17] - 2024-12-22
|
||||
### Changed
|
||||
- Several GUI improvements [`#1672`](https://github.com/vrc-get/vrc-get/pull/1672) [`#1771`](https://github.com/vrc-get/vrc-get/pull/1771) [`#1775`](https://github.com/vrc-get/vrc-get/pull/1775) [`#1772`](https://github.com/vrc-get/vrc-get/pull/1772) [`#1779`](https://github.com/vrc-get/vrc-get/pull/1779)
|
||||
- Removed `-debugCodeOptimization` from default unity arguments [`#1742`](https://github.com/vrc-get/vrc-get/pull/1742)
|
||||
- Projects that failes to resolve will also be added to Project List now [`#1748`](https://github.com/vrc-get/vrc-get/pull/1748)
|
||||
- Previsously project dir is created but not added to list
|
||||
- Dialog is shown when some installing packages are not found [`#1749`](https://github.com/vrc-get/vrc-get/pull/1749) [`#1776`](https://github.com/vrc-get/vrc-get/pull/1776)
|
||||
- The new dialog also suggest you to google & add repository for the package
|
||||
- Previously the first package we could not found are shown on the error toast but now collect and show missing packages as many as possible
|
||||
|
||||
### Fixed
|
||||
- Prerelease version is choosen even if good stable version exists [`#1745`](https://github.com/vrc-get/vrc-get/pull/1745)
|
||||
|
||||
## [0.1.16] - 2024-11-12
|
||||
### Added
|
||||
- Support for China version of Unity releases like `2022.3.22f1c1` `#1558
|
||||
- `rpm` `deb` packaging for Linux [`#1575`](https://github.com/vrc-get/vrc-get/pull/1575)
|
||||
- This is to test how good / bad `rpm` or `deb` distribution is.
|
||||
- We **may** create dnf / apt package repository in the future, but not planned for now.
|
||||
- Skipping finding legacy assets when downgrading / upgrading / reinstalling package [`#1581`](https://github.com/vrc-get/vrc-get/pull/1581)
|
||||
- This will speed up the process of downgrading / upgrading / reinstalling package.
|
||||
|
||||
### Changed
|
||||
- Separated quick open actions to own settings box. [`#1496`](https://github.com/vrc-get/vrc-get/pull/1496)
|
||||
- Improved behavior with downloading package error [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
|
||||
- Installing unlocked package is now possible with warning [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
|
||||
- Added many logs for installing package [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
|
||||
- Migration feature is no longer marked as experimental [`#1559`](https://github.com/vrc-get/vrc-get/pull/1559)
|
||||
- Several UX improvements [`#1561`](https://github.com/vrc-get/vrc-get/pull/1561) [`#1565`](https://github.com/vrc-get/vrc-get/pull/1565) [`#1569`](https://github.com/vrc-get/vrc-get/pull/1569) [`#1571`](https://github.com/vrc-get/vrc-get/pull/1571) [`#1573`](https://github.com/vrc-get/vrc-get/pull/1573)
|
||||
- Added more error log [`#1652`](https://github.com/vrc-get/vrc-get/pull/1652)
|
||||
- Improved error message when specified drive not found [`#1653`](https://github.com/vrc-get/vrc-get/pull/1653)
|
||||
|
||||
### Fixed
|
||||
- Clicking VCC link while adding vpm repository would close previously opened add repository dialog [`#1570`](https://github.com/vrc-get/vrc-get/pull/1570)
|
||||
- Opnening Templetes directory might fails [`#1641`](https://github.com/vrc-get/vrc-get/pull/1641)
|
||||
- Backup file name is incorrect if project name contains '.' [`#1648`](https://github.com/vrc-get/vrc-get/pull/1648)
|
||||
- Error creating project if the project path is "C:" [`#1651`](https://github.com/vrc-get/vrc-get/pull/1651)
|
||||
- "missing field Verison" error if some unity version is missing [`#1654`](https://github.com/vrc-get/vrc-get/pull/1654)
|
||||
|
||||
## [0.1.15] - 2024-09-05
|
||||
### Added
|
||||
- System Information card to Settings Page [`#1406`](https://github.com/vrc-get/vrc-get/pull/1406)
|
||||
- Traditional Chinese translation [`#1442`](https://github.com/vrc-get/vrc-get/pull/1442)
|
||||
- Reinstall some selected packages [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
|
||||
- Install and Upgrade packages at once [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
|
||||
- Upgrade to the stable latest version even if some package has newer prerelease version [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
|
||||
- Buttons to open settings, logs, and templates location [`#1451`](https://github.com/vrc-get/vrc-get/pull/1451)
|
||||
- Error page [`#1457`](https://github.com/vrc-get/vrc-get/pull/1457)
|
||||
- Ctrl + F on Log, Projects List, and Project page will focus search box on the page [`#1485`](https://github.com/vrc-get/vrc-get/pull/1485)
|
||||
|
||||
### Changed
|
||||
- GitHub Releases for ALCOM is no longer prereleases
|
||||
- Moved log files to `<vpm-home>/vrc-get/gui-logs` [`#1446`](https://github.com/vrc-get/vrc-get/pull/1446)
|
||||
- Logs pages overhaul [`#1456`](https://github.com/vrc-get/vrc-get/pull/1456)
|
||||
|
||||
### Fixed
|
||||
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork [`#1402`](https://github.com/vrc-get/vrc-get/pull/1402)
|
||||
- This is typically seen on ExFAT or FAT32 filesystems, not on APFS or HFS+ filesystems.
|
||||
- macOS internally creates files starting with `._` for resource fork if the filesystem does not support resource fork.
|
||||
- vrc-get-vpm does not handle this file correctly and fails to uninstall the package.
|
||||
- environment version mismatch error after resolving packages `#1447`
|
||||
- Raw error for InstallAsUnlocked is shown on gui `#1448`
|
||||
- Ctrl + F on Windows will show the search box by WebView2 `#1485`
|
||||
|
||||
### Security
|
||||
- environment version mismatch error after resolving packages [`#1447`](https://github.com/vrc-get/vrc-get/pull/1447)
|
||||
- Raw error for InstallAsUnlocked is shown on gui [`#1448`](https://github.com/vrc-get/vrc-get/pull/1448)
|
||||
- Ctrl + F on Windows will show the search box by WebView2 [`#1485`](https://github.com/vrc-get/vrc-get/pull/1485)
|
||||
- Project Path is shown instead of Project Name [`#1484`](https://github.com/vrc-get/vrc-get/pull/1484)
|
||||
|
||||
## [0.1.14] - 2024-08-13
|
||||
### Added
|
||||
|
|
@ -446,7 +703,19 @@ Release pipeline fixes
|
|||
- Apple code signing [`#422`](https://github.com/anatawa12/vrc-get/pull/422)
|
||||
- Migrate vpm 2019 project to 2022 [`#435`](https://github.com/anatawa12/vrc-get/pull/435)
|
||||
|
||||
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.14...HEAD
|
||||
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.6...HEAD
|
||||
[1.1.6]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.5...gui-v1.1.6
|
||||
[1.1.5]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.4...gui-v1.1.5
|
||||
[1.1.4]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.3...gui-v1.1.4
|
||||
[1.1.3]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.2...gui-v1.1.3
|
||||
[1.1.2]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.1...gui-v1.1.2
|
||||
[1.1.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.0...gui-v1.1.1
|
||||
[1.1.0]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.1...gui-v1.1.0
|
||||
[1.0.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.0...gui-v1.0.1
|
||||
[1.0.0]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.17...gui-v1.0.0
|
||||
[0.1.17]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.16...gui-v0.1.17
|
||||
[0.1.16]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.15...gui-v0.1.16
|
||||
[0.1.15]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.14...gui-v0.1.15
|
||||
[0.1.14]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.13...gui-v0.1.14
|
||||
[0.1.13]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.12...gui-v0.1.13
|
||||
[0.1.12]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.11...gui-v0.1.12
|
||||
|
|
|
|||
90
CHANGELOG.md
90
CHANGELOG.md
|
|
@ -8,34 +8,87 @@ The format is based on [Keep a Changelog].
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Per-package `headers` field support `#718`
|
||||
- Since this is adding support for missing features, I treat this as a bugfix and not bump minor version.
|
||||
- De-duplicating duplicated projects or Unity in VCC project list `#1081`
|
||||
- `vrc-get cache clear`, command to clear package cache `#1204`
|
||||
- Importing / Exporting Repositories list `#1209`
|
||||
- `vrc-get repo import <list file>` and `vrc-get repo export`
|
||||
- User Package Management `#1222`
|
||||
- This release adds `vrc-get user-package` subcommands to manage user packages.
|
||||
- `vrc-get reinstall <package id>` to reinstall specified packages `#1223`
|
||||
|
||||
### Changed
|
||||
- Error message will be shown if the SHA256 hash of the downloaded zip file does not match with the hash in the repository `#1183`
|
||||
- Currently, official VCC does not verify the hash of the downloaded zip file, but it's better to verify the hash.
|
||||
- For compatibility, even if the hash does not match, the file will be extracted with an error message.
|
||||
- In the future, we may make this a hard error.
|
||||
- Improved saving interacting with setting files `#2485` `#2710`
|
||||
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
|
||||
- This should reduce losing settings after crashing ALCOM or PC.
|
||||
- null as vpmDependencies value is not allowed `#2709`
|
||||
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
|
||||
- Improved robustness for package installation errors `#2844`
|
||||
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
|
||||
- Backslashes in path in zip file are now treated as path separator on unix `#2845`
|
||||
- This fixes problem with Gesture Manager 3.9.7
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
- Unity from Unity Hub will be registered as manually registered Unity `#1081`
|
||||
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork `#1402`
|
||||
- Fix 'Detected Loop' panic with valid database file `#2607`
|
||||
- Panic when resolving projects where dependency packages depend on newer versions of locked packages `#2822`
|
||||
- Warning for backup/project path in AppData folder not shown when path is in Roaming or LocalLow [`#2827`](https://github.com/vrc-get/vrc-get/pull/2827)
|
||||
- Unclear error message for invalid version name or version range `#2842`
|
||||
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored `#2930`
|
||||
- They are formerly rejected as invalid url
|
||||
|
||||
### Security
|
||||
- Package hash checks are now enforced when installing packages `#2849`
|
||||
- It has been about two years since the error message for package hash mismatches was introduced.
|
||||
- It is now enforced for security.
|
||||
|
||||
## [1.9.1] - 2025-07-28
|
||||
### Changed
|
||||
- Changed how we read VCC's project information [`#1997`](https://github.com/vrc-get/vrc-get/pull/1997)
|
||||
- Along with this, building this project no longer needs dotnet SDK to build.
|
||||
- Migrated the project to Rust 2024 [`#1956`](https://github.com/vrc-get/vrc-get/pull/1956)
|
||||
- This is internal changes should not cause behavior changes
|
||||
- This would require Rust 1.85 for building this project
|
||||
- Removed `cargo-about` from build-time dependency [`#1961`](https://github.com/vrc-get/vrc-get/pull/1961)
|
||||
- This is internal changes should not cause behavior changes
|
||||
- I listed here since this may need update on package metadata of some package managers
|
||||
- The method to retrieve the list of Unity from Unity Hub [`#1808`](https://github.com/vrc-get/vrc-get/pull/1808) [`#1971`](https://github.com/vrc-get/vrc-get/pull/1971)
|
||||
- You now can select multiple folders at once to adding project [`#2018`](https://github.com/vrc-get/vrc-get/pull/2018)
|
||||
- I didn't know official VCC had such a feature. Sorry for lack of feature!
|
||||
- The requirements for unity project [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
|
||||
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
|
||||
|
||||
### Fixed
|
||||
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` [`#2209`](https://github.com/vrc-get/vrc-get/pull/2209)
|
||||
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
|
||||
|
||||
## [1.9.0] - 2025-01-01
|
||||
### Added
|
||||
- Per-package `headers` field support [`#718`](https://github.com/vrc-get/vrc-get/pull/718)
|
||||
- Since this is adding support for missing features, I treat this as a bugfix and not bump minor version.
|
||||
- De-duplicating duplicated projects or Unity in VCC project list [`#1081`](https://github.com/vrc-get/vrc-get/pull/1081)
|
||||
- `vrc-get cache clear`, command to clear package cache [`#1204`](https://github.com/vrc-get/vrc-get/pull/1204)
|
||||
- Importing / Exporting Repositories list [`#1209`](https://github.com/vrc-get/vrc-get/pull/1209)
|
||||
- `vrc-get repo import <list file>` and `vrc-get repo export`
|
||||
- User Package Management [`#1222`](https://github.com/vrc-get/vrc-get/pull/1222)
|
||||
- This release adds `vrc-get user-package` subcommands to manage user packages.
|
||||
- `vrc-get reinstall <package id>` to reinstall specified packages [`#1223`](https://github.com/vrc-get/vrc-get/pull/1223)
|
||||
- Skipping finding legacy assets when downgrading / upgrading / reinstalling package [`#1581`](https://github.com/vrc-get/vrc-get/pull/1581)
|
||||
- This will speed up the process of downgrading / upgrading / reinstalling package.
|
||||
|
||||
### Changed
|
||||
- Error message will be shown if the SHA256 hash of the downloaded zip file does not match with the hash in the repository [`#1183`](https://github.com/vrc-get/vrc-get/pull/1183)
|
||||
- Currently, official VCC does not verify the hash of the downloaded zip file, but it's better to verify the hash.
|
||||
- For compatibility, even if the hash does not match, the file will be extracted with an error message.
|
||||
- In the future, we may make this a hard error.
|
||||
- Migration feature is no longer marked as experimental [`#1559`](https://github.com/vrc-get/vrc-get/pull/1559)
|
||||
|
||||
### Fixed
|
||||
- Unity from Unity Hub will be registered as manually registered Unity [`#1081`](https://github.com/vrc-get/vrc-get/pull/1081)
|
||||
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork [`#1402`](https://github.com/vrc-get/vrc-get/pull/1402)
|
||||
- This is typically seen on ExFAT or FAT32 filesystems, not on APFS or HFS+ filesystems.
|
||||
- macOS internally creates files starting with `._` for resource fork if the filesystem does not support resource fork.
|
||||
- vrc-get-vpm does not handle this file correctly and fails to uninstall the package.
|
||||
- Prerelease version is choosen even if good stable version exists [`#1745`](https://github.com/vrc-get/vrc-get/pull/1745)
|
||||
|
||||
### Security
|
||||
## [1.8.2] - 2024-10-16
|
||||
### Fixed
|
||||
- Hotfix: Added contact information about author of the project to the User-Agent
|
||||
|
||||
## [1.8.1] - 2024-05-13
|
||||
### Changed
|
||||
|
|
@ -453,7 +506,10 @@ The format is based on [Keep a Changelog].
|
|||
## [0.1.0] - 2023-01-25
|
||||
Initial Release
|
||||
|
||||
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.8.1...HEAD
|
||||
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.9.1...HEAD
|
||||
[1.9.1]: https://github.com/vrc-get/vrc-get/compare/v1.9.0...v1.9.1
|
||||
[1.9.0]: https://github.com/vrc-get/vrc-get/compare/v1.8.2...v1.9.0
|
||||
[1.8.2]: https://github.com/vrc-get/vrc-get/compare/v1.8.1...v1.8.2
|
||||
[1.8.1]: https://github.com/vrc-get/vrc-get/compare/v1.8.0...v1.8.1
|
||||
[1.8.0]: https://github.com/vrc-get/vrc-get/compare/v1.7.1...v1.8.0
|
||||
[1.7.1]: https://github.com/anatawa12/vrc-get/compare/v1.7.0...v1.7.1
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ This project consists of multiple projects.
|
|||
Each project may have its own contribution guidelines, so please read the `CONTRIBUTING.md` file in the project folder.
|
||||
|
||||
- [vrc-get CLI](vrc-get/CONTRIBUTING.md) (not available yet)
|
||||
- [vrc-get LiteDB](vrc-get-litedb/CONTRIBUTING.md)
|
||||
- [vrc-get GUI](vrc-get-gui/CONTRIBUTING.md)
|
||||
- [vrc-get VPM](vrc-get-vpm/CONTRIBUTING.md) (not available yet)
|
||||
|
||||
|
|
@ -23,11 +22,7 @@ so it is recommended to install with rustup and update it regularly.
|
|||
|
||||
Not only Rust, some projects may require additional dependencies.
|
||||
|
||||
For VCC-related features of vrc-get, and ALCOM, you need to install .NET SDK to work with.
|
||||
|
||||
Please refer to the [.NET installation guide](https://dotnet.microsoft.com/download) to install .NET SDK if you don't have it.
|
||||
|
||||
For ALCOM, you need to install any LTS version of Node.js and npm for building the frontend.
|
||||
For ALCOM, you need to install Node.js >=20 supported and npm for building the frontend.
|
||||
|
||||
Please refer to the [Node.js installation guide](https://nodejs.org/en/download/) to install Node.js if you don't have it.
|
||||
|
||||
|
|
@ -45,7 +40,3 @@ You can work on any OS system, but this repository generally uses Symbolic Links
|
|||
|
||||
For Windows machines, you may need to set up so your current user can create symbolic links.
|
||||
Please refer to git-for-windows documentation page <https://github.com/git-for-windows/git/wiki/Symbolic-Links>
|
||||
|
||||
In addition, when you work with `vrc-get-litedb` project,
|
||||
you need to clone the repository with `--recurse-submodules` option
|
||||
(or setup submodules manually with `git submodule update --init --recursive` after cloning).
|
||||
|
|
|
|||
4879
Cargo.lock
generated
4879
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
15
Cargo.toml
15
Cargo.toml
|
|
@ -2,11 +2,10 @@
|
|||
resolver = "2"
|
||||
|
||||
members = [
|
||||
"build-check-static-link",
|
||||
"build-updater-json",
|
||||
"xtask",
|
||||
"vrc-get",
|
||||
"vrc-get-gui",
|
||||
"vrc-get-litedb",
|
||||
"vrc-get-gui/windows-installer-wrapper",
|
||||
"vrc-get-vpm",
|
||||
]
|
||||
|
||||
|
|
@ -14,15 +13,21 @@ members = [
|
|||
# "build-check-static-link" is excluded since "build-check-static-link" is not a part of the main project, a utility for project
|
||||
default-members = [
|
||||
"vrc-get",
|
||||
"vrc-get-litedb",
|
||||
"vrc-get-vpm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
license = "MIT"
|
||||
authors = ["anatawa12 <anatawa12@icloud.com>"]
|
||||
homepage = "https://github.com/anatawa12/vrc-get#readme"
|
||||
repository = "https://github.com/anatawa12/vrc-get"
|
||||
readme = "README.md"
|
||||
|
||||
[profile.xtask]
|
||||
inherits = "dev"
|
||||
incremental = false
|
||||
debug = 1
|
||||
opt-level = 0
|
||||
lto = "off"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
[![MacPorts Version][shields-macports-vrc-get]][macports-vrc-get]
|
||||
[![Scoop Version][shields-scoop-version]][scoop-vrc-get]
|
||||
[![AUR Version][shields-aur-version]][aur-vrc-get]
|
||||
[![WinGet Version][shields-winget-version]][winget-vrc-get]
|
||||
|
||||
Open Source command line client of VRChat Package Manager,
|
||||
the main feature of VRChat Creator Companion (VCC), which supports Windows, Linux, and macOS.
|
||||
|
|
@ -141,6 +142,7 @@ See [README of ALCOM][alcom] for more details.
|
|||
[shields-homebrew-version]: https://img.shields.io/homebrew/v/vrc-get
|
||||
[shields-macports-vrc-get]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Fvrc-get%2F&query=%24.version&label=macports
|
||||
[shields-scoop-version]: https://img.shields.io/scoop/v/vrc-get?bucket=https%3A%2F%2Fgithub.com%2Fbabo4d%2Fscoop-xrtools
|
||||
[shields-winget-version]: https://img.shields.io/winget/v/anatawa12.vrc-get
|
||||
|
||||
<!-- TODO: macports: https://github.com/badges/shields/issues/9588 -->
|
||||
|
||||
|
|
@ -162,6 +164,7 @@ See [README of ALCOM][alcom] for more details.
|
|||
[homebrew-vrc-get]: https://formulae.brew.sh/formula/vrc-get
|
||||
[macports-vrc-get]: https://ports.macports.org/port/vrc-get
|
||||
[scoop-vrc-get]: https://github.com/babo4d/scoop-xrtools/blob/master/bucket/vrc-get.json
|
||||
[winget-vrc-get]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/anatawa12/vrc-get
|
||||
|
||||
## Contribution
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
/*
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[package]
|
||||
name = "build-check-static-link"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dependencies.object]
|
||||
version = "0.36"
|
||||
default-features = false
|
||||
features = [
|
||||
"read_core",
|
||||
"macho",
|
||||
"pe",
|
||||
"elf",
|
||||
]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
This crate is to ensure built binary is statically linked or dynamically linked to the system libraries.
|
||||
|
||||
this crate use `object` crate to read the binary file.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
cargo run -p build-check-static-link <path/to/binary>
|
||||
```
|
||||
|
||||
exits with zero if statically linked or linked with allowed dynamic libraries, otherwise exits with non-zero.
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
use object::{Endian, Endianness, FileKind, Object};
|
||||
use std::fs;
|
||||
use std::process::exit;
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args();
|
||||
let _ = args.next();
|
||||
let mut success = true;
|
||||
for arg in args {
|
||||
if arg.ends_with(".d") {
|
||||
println!("skipping .d file: {}", arg);
|
||||
continue;
|
||||
}
|
||||
let binary = std::path::Path::new(&arg);
|
||||
let binary = fs::read(binary).unwrap();
|
||||
|
||||
success |= match FileKind::parse(binary.as_slice()).expect("detecting type") {
|
||||
FileKind::MachO64 => process_mach_64::<Endianness>(&binary),
|
||||
FileKind::Pe64 => process_pe_64(&binary),
|
||||
FileKind::Elf64 => process_elf_64::<Endianness>(&binary),
|
||||
unknown => panic!("unknown file type: {:?}", unknown),
|
||||
};
|
||||
}
|
||||
|
||||
if success {
|
||||
exit(0)
|
||||
} else {
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_mach_64<E: Endian>(binary: &[u8]) -> bool {
|
||||
use object::macho::*;
|
||||
use object::read::macho::*;
|
||||
|
||||
let mut success = true;
|
||||
|
||||
let parsed = MachHeader64::<E>::parse(binary, 0).expect("failed to parse binary");
|
||||
let endian = parsed.endian().unwrap();
|
||||
|
||||
let mut commands = parsed
|
||||
.load_commands(endian, binary, 0)
|
||||
.expect("parsing binary");
|
||||
while let Some(command) = commands.next().expect("reading binary") {
|
||||
if let Some(dylib) = command.dylib().unwrap() {
|
||||
let dylib = command.string(endian, dylib.dylib.name).unwrap();
|
||||
match dylib {
|
||||
| b"/System/Library/Frameworks/Security.framework/Versions/A/Security"
|
||||
| b"/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"
|
||||
| b"/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
|
||||
| b"/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation"
|
||||
| b"/usr/lib/libobjc.A.dylib"
|
||||
| b"/usr/lib/libiconv.2.dylib"
|
||||
| b"/usr/lib/libSystem.B.dylib"
|
||||
=> {
|
||||
// known system library
|
||||
println!("system dylib: {}", std::str::from_utf8(dylib).unwrap());
|
||||
}
|
||||
unknown => {
|
||||
println!("ERROR: unknown dylib: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
|
||||
success = false;
|
||||
},
|
||||
}
|
||||
} else if command.cmd() == LC_LOAD_DYLINKER {
|
||||
let data: &DylinkerCommand<E> = command.data().expect("parse LC_LOAD_DYLINKER");
|
||||
if command.string(endian, data.name).unwrap() != b"/usr/lib/dyld" {
|
||||
println!("ERROR: dylinker is not /usr/lib/dyld");
|
||||
success = false;
|
||||
} else {
|
||||
println!("dylinker: /usr/lib/dyld");
|
||||
}
|
||||
}
|
||||
}
|
||||
success
|
||||
}
|
||||
|
||||
fn process_pe_64(binary: &[u8]) -> bool {
|
||||
use object::read::pe::*;
|
||||
use object::LittleEndian as LE;
|
||||
|
||||
let mut success = true;
|
||||
let parsed = PeFile64::parse(binary).expect("failed to parse binary");
|
||||
|
||||
let table = parsed.import_table().unwrap().unwrap();
|
||||
let mut iter = table.descriptors().unwrap();
|
||||
while let Some(x) = iter.next().unwrap() {
|
||||
let dll = table.name(x.name.get(LE)).unwrap();
|
||||
match dll.to_ascii_lowercase().as_slice() {
|
||||
| b"advapi32.dll"
|
||||
| b"kernel32.dll"
|
||||
| b"bcrypt.dll" // TODO: check if this is a system library
|
||||
| b"ntdll.dll"
|
||||
| b"shell32.dll"
|
||||
| b"ole32.dll"
|
||||
| b"ws2_32.dll"
|
||||
| b"crypt32.dll"
|
||||
=> {
|
||||
println!("system dll: {}", std::str::from_utf8(dll).unwrap());
|
||||
// known system library
|
||||
}
|
||||
unknown => {
|
||||
println!("ERROR: unknown dll: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
|
||||
success = false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
success
|
||||
}
|
||||
|
||||
fn process_elf_64<E: Endian>(binary: &[u8]) -> bool {
|
||||
use object::elf::*;
|
||||
use object::read::elf::*;
|
||||
|
||||
let mut success = true;
|
||||
|
||||
let parsed = ElfFile64::<E>::parse(binary).expect("failed to parse binary");
|
||||
|
||||
for x in parsed.imports().unwrap() {
|
||||
println!(
|
||||
"dynamic importing symbol: {}",
|
||||
std::str::from_utf8(x.name()).unwrap()
|
||||
);
|
||||
success = false;
|
||||
}
|
||||
|
||||
for segment in parsed.elf_program_headers() {
|
||||
if segment.p_type.get(parsed.endian()) == PT_INTERP {
|
||||
let data = segment.data(parsed.endian(), parsed.data()).unwrap();
|
||||
println!("interpreter: {:?}", std::str::from_utf8(data).unwrap());
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
success
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
/*
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
[package]
|
||||
name = "build-updater-json"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["now", "serde"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// see https://tauri.app/v1/guides/distribution/updater/ for json format
|
||||
|
||||
use chrono::{Timelike, Utc};
|
||||
use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdaterJson {
|
||||
version: String,
|
||||
notes: String,
|
||||
pub_date: chrono::DateTime<Utc>,
|
||||
platforms: IndexMap<String, Platform>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Platform {
|
||||
signature: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// consts
|
||||
const DOWNLOAD_URL_BASE: &str =
|
||||
"https://github.com/vrc-get/vrc-get/releases/download/gui-v{version}";
|
||||
let platform_file_name = [
|
||||
("darwin-x86_64", "ALCOM-{version}-universal.app.tar.gz"),
|
||||
("darwin-aarch64", "ALCOM-{version}-universal.app.tar.gz"),
|
||||
("linux-x86_64", "alcom-{version}-x86_64.AppImage.tar.gz"),
|
||||
//("linux-aarch64", "alcom-{version}-aarch64.AppImage.tar.gz"),
|
||||
("windows-x86_64", "ALCOM-{version}-x86_64-setup.nsis.zip"),
|
||||
//("windows-aarch64", "ALCOM-{version}-aarch64-setup.nsis.zip"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<IndexMap<_, _>>();
|
||||
|
||||
let version = std::env::var("GUI_VERSION").expect("GUI_VERSION not set");
|
||||
|
||||
let base_url = DOWNLOAD_URL_BASE.replace("{version}", &version);
|
||||
|
||||
// create platforms info
|
||||
let mut platforms = IndexMap::new();
|
||||
for (platform, file_name) in platform_file_name {
|
||||
let file_name = file_name.replace("{version}", &version);
|
||||
|
||||
std::fs::metadata(format!("assets/{file_name}"))
|
||||
.unwrap_or_else(|e| panic!("{}: {}", file_name, e));
|
||||
|
||||
let signature = std::fs::read_to_string(format!("assets/{file_name}.sig"))
|
||||
.unwrap_or_else(|e| panic!("{}.sig: {}", file_name, e));
|
||||
|
||||
let url = format!("{}/{}", base_url, file_name);
|
||||
platforms.insert(platform.to_string(), Platform { signature, url });
|
||||
}
|
||||
|
||||
let stable_notes = get_notes("vrc-get-gui/notes.txt".as_ref());
|
||||
let beta_notes = get_notes("vrc-get-gui/notes-beta.txt".as_ref());
|
||||
let is_beta = version.contains('-');
|
||||
|
||||
let mut updater = UpdaterJson {
|
||||
version,
|
||||
notes: String::new(),
|
||||
pub_date: Utc::now().with_nanosecond(0).unwrap(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
if !is_beta {
|
||||
updater.notes = stable_notes;
|
||||
write_json("updater.json", &updater);
|
||||
}
|
||||
updater.notes = beta_notes;
|
||||
write_json("updater-beta.json", &updater);
|
||||
}
|
||||
|
||||
fn write_json(path: impl AsRef<Path>, json: impl Serialize) {
|
||||
let json = serde_json::to_string_pretty(&json).unwrap();
|
||||
std::fs::write(path, json).expect("write updater.json");
|
||||
}
|
||||
|
||||
fn get_notes(path: &Path) -> String {
|
||||
let notes = std::fs::read_to_string(path).expect("read notes.txt");
|
||||
// lines starts with # are comments
|
||||
notes
|
||||
.trim_end()
|
||||
.lines()
|
||||
.filter(|x| !x.starts_with('#'))
|
||||
.map(|x| x.trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
45
docs/repository-list-file-format.md
Normal file
45
docs/repository-list-file-format.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Repository List file format
|
||||
|
||||
This document describes the format of the repository list file used in ALCOM / vrc-get.
|
||||
|
||||
## File format
|
||||
|
||||
1. The file is a UTF-8 encoded text file.
|
||||
2. For each line, text after '#' will be ignored as a comment.
|
||||
3. Each line contains a repository URL, or empty.
|
||||
4. Each line is trimmed before processing. (this mean any line only with spaces will be ignored)
|
||||
5. Each repository line should only contain a valid URL.
|
||||
6. The URL schema must be `http`, `https`, or `vcc`.\
|
||||
Other schemas are ignored and might be recognized for other purposes in the future.
|
||||
7. If the URL is a `http` or `https` URL, the URL represents a VPM repository without headers.
|
||||
8. If the URL is a `vcc` URL, the URL should be a VCC URL to add VPM repository, which is described below.\
|
||||
This notation is used to express the repository with headers.
|
||||
|
||||
## VCC URL format
|
||||
|
||||
The VCC URL to add VPM Repository will be a valid URL with the following format:
|
||||
|
||||
- schema must be `vcc`
|
||||
- the host part must be `vpm`
|
||||
- the path part must be `/addRepo`
|
||||
- the query part must contain single `url` parameter which represents the repository URL to add.
|
||||
- the query part may contain `headers[]` parameter with represents the HTTP headers for the repository.
|
||||
- query value will be split by `:` and prior part will be the header name and the rest will be the header value.
|
||||
|
||||
## Examples
|
||||
|
||||
```text
|
||||
# This is a comment
|
||||
http://example.com/repo
|
||||
https://example.com/repo
|
||||
|
||||
vcc://vpm/addRepo?url=http://example.com/repo&headers[]=header-name:header-value
|
||||
```
|
||||
|
||||
This file represents a repository list with the following repositories:
|
||||
|
||||
- `http://example.com/repo`
|
||||
- `https://example.com/repo`
|
||||
- `http://example.com/repo` with a custom header `header-name:header-value`
|
||||
|
||||
Another example may be found at the `repositories.txt` at the root of this repository.
|
||||
6
vrc-get-gui/.gitignore
vendored
6
vrc-get-gui/.gitignore
vendored
|
|
@ -4,11 +4,5 @@
|
|||
.pnp.js
|
||||
/gen
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/build/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
|
|
|||
1
vrc-get-gui/.npmrc
Normal file
1
vrc-get-gui/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
ignore-scripts=true
|
||||
|
|
@ -25,12 +25,13 @@
|
|||
/*.ts
|
||||
/*.tsx
|
||||
/*.json
|
||||
/index.html
|
||||
|
||||
# documentation
|
||||
/*.md
|
||||
|
||||
# template
|
||||
/templates/**
|
||||
/project-templates/**
|
||||
|
||||
# icons
|
||||
/app-icon.afdesign
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "vrc-get-gui"
|
||||
version = "0.1.15-beta.0"
|
||||
description = "A Tauri App"
|
||||
version = "1.1.7-beta.0"
|
||||
description = "A fast open-source alternative of VRChat Creator Companion"
|
||||
|
||||
homepage.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
@ -16,23 +16,25 @@ path = "src/main.rs"
|
|||
[build-dependencies]
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
tauri-build = { version = "2.0.0-rc.3", features = [ "config-toml" ] }
|
||||
tauri-build = { version = "2", features = [ "config-toml" ] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2.0.0-rc.3", features = [ "config-toml" ] }
|
||||
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management", "tokio"] }
|
||||
reqwest = "0.12"
|
||||
specta = { version = "2.0.0-rc.20", features = [ "chrono", "url", "indexmap" ] }
|
||||
tauri-specta = { version = "2.0.0-rc.17", features = ["typescript"] }
|
||||
specta-typescript = "0.0.7"
|
||||
serde_with = { version = "3", features = ["base64"] }
|
||||
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
|
||||
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
|
||||
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
|
||||
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
|
||||
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
|
||||
specta-typescript = "0.0.11"
|
||||
open = "5"
|
||||
arc-swap = "1"
|
||||
log = { version = "0.4", features = [ "std", "kv" ] }
|
||||
chrono = { version = "0.4", features = [ "serde" ] }
|
||||
ringbuffer = "0.15"
|
||||
ringbuffer = "0.16"
|
||||
tokio = { version = "1", features = ["process"] }
|
||||
tokio-util = "0.7"
|
||||
fs_extra = "1"
|
||||
indexmap = "2"
|
||||
futures = "0.3"
|
||||
|
|
@ -40,28 +42,43 @@ tar = "0.4"
|
|||
flate2 = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
trash = "5"
|
||||
async_zip = { version = "0.0.17", features = ["deflate", "tokio"] }
|
||||
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
||||
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
|
||||
async-stream = "0.3"
|
||||
tauri-plugin-single-instance = "2.0.0-rc.0"
|
||||
tauri-plugin-updater = "2.0.0-rc.1"
|
||||
tauri-plugin-dialog = "2.0.0-rc.1"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
minisign-verify = "0.2"
|
||||
base64 = "0.22"
|
||||
semver = "1"
|
||||
tempfile = "3"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
sys-locale = "0.3"
|
||||
log-panics = { version = "2", features = ["with-backtrace"] }
|
||||
url = "2"
|
||||
dirs-next = "2"
|
||||
yoke = { version = "0.7", features = ["derive"] }
|
||||
yoke = { version = "0.8", features = ["derive"] }
|
||||
atomicbox = "0.4"
|
||||
stable_deref_trait = "1"
|
||||
itertools = "0.14"
|
||||
sysinfo = "0.39.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation"] }
|
||||
winreg = "0.52"
|
||||
windows = { version = "0.62", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
winreg = "0.56"
|
||||
wmi = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.26"
|
||||
plist = { version = "1" }
|
||||
objc2-app-kit = { version = "0.3.0", features = ['NSWorkspace', "block2"] }
|
||||
objc2-foundation = "0.3.0"
|
||||
block2 = "0.6.0"
|
||||
objc2 = "0.6.0"
|
||||
dispatch2 = "0.3.0"
|
||||
rlimit = "0.11.0"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.29", features = ["fs"] }
|
||||
nix = { version = "0.31", features = ["fs", "mount"] }
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
|
|
@ -69,7 +86,11 @@ nix = { version = "0.29", features = ["fs"] }
|
|||
# DO NOT REMOVE!!
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
|
||||
# rustc-check-cfg is not supported by tauri 1.x yet so we need to ignore it
|
||||
# https://github.com/tauri-apps/tauri/pull/10392
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(dev)'] }
|
||||
no-self-updater = []
|
||||
|
||||
# Devtools
|
||||
#
|
||||
# Enables browser devtools for debugging javascript part.
|
||||
# It's not recommended to enable this for production builds,
|
||||
# development use only!
|
||||
devtools = ["tauri/devtools"]
|
||||
|
|
|
|||
|
|
@ -15,5 +15,48 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ALCOM Project Template</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.anatawa12.vrc-get.alcomtemplate</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.anatawa12.vrc-get.alcomtemplate</string>
|
||||
<key>UTTypeIcons</key>
|
||||
<dict>
|
||||
<key>UTTypeIconText</key>
|
||||
<string>Template</string>
|
||||
<key>UTTypeIconBackgroundName</key>
|
||||
<string></string>
|
||||
<key>UTTypeIconBadgeName</key>
|
||||
<string>icon.icns</string>
|
||||
</dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.content</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>ALCOM Project Template</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>alcomtemplate</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,114 @@
|
|||
# ALCOM (experimental)
|
||||
# ALCOM
|
||||
|
||||
This folder contains the experimental GUI version of vrc-get, ALCOM.
|
||||
[![Github Release][shields-github-version]][release-alcom]
|
||||
[![Homebrew Version][shields-homebrew-version]][homebrew-alcom]
|
||||
[![Scoop Version][shields-scoop-version]][scoop-alcom]
|
||||
[![AUR Version][shields-aur-version]][aur-alcom]
|
||||
[![WinGet Version][shields-winget-version]][winget-alcom]
|
||||
<!-- [![MacPorts Version][shields-macports-vrc-get]][macports-vrc-get] -->
|
||||
|
||||
[Homepage (Help Wanted)](https://vrc-get.anatawa12.com/alcom/)
|
||||
[shields-github-version]: https://img.shields.io/github/v/release/vrc-get/vrc-get?filter=gui-v*
|
||||
[shields-homebrew-version]: https://img.shields.io/homebrew/cask/v/alcom
|
||||
[shields-scoop-version]: https://img.shields.io/scoop/v/vrc-alcom?bucket=https%3A%2F%2Fgithub.com%2Fbabo4d%2Fscoop-xrtools
|
||||
[shields-aur-version]: https://img.shields.io/aur/version/alcom
|
||||
[shields-winget-version]: https://img.shields.io/winget/v/anatawa12.ALCOM
|
||||
<!-- [shields-macports-vrc-get]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Falcom%2F&query=%24.version&label=macports -->
|
||||
|
||||
<!-- TODO: macports: https://github.com/badges/shields/issues/9588 -->
|
||||
|
||||
[release-alcom]: https://github.com/vrc-get/vrc-get/releases?q=gui-v1
|
||||
[homebrew-alcom]: https://formulae.brew.sh/cask/alcom
|
||||
[scoop-alcom]: https://github.com/babo4d/scoop-xrtools/blob/master/bucket/vrc-alcom.json
|
||||
[aur-alcom]: https://aur.archlinux.org/packages/alcom
|
||||
[winget-alcom]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/anatawa12/ALCOM
|
||||
<!-- [macports-vrc-get]: https://ports.macports.org/port/alcom -->
|
||||
|
||||
[scoop-xrtools]: https://github.com/babo4d/scoop-xrtools/
|
||||
|
||||
A crossplatform fast open-source alternative of VRChat Creator Companion
|
||||
|
||||
[Homepage](https://vrc-get.anatawa12.com/alcom/)
|
||||
|
||||
## Installation
|
||||
|
||||
The recommended way to install ALCOM is download from [GitHub Releases][alcom-releases].
|
||||
The recommended way to install ALCOM is download from [Website][alcom-site].
|
||||
|
||||
Or you can install ALCOM from package managers like [Homebrew][homebrew-alcom], [Scoop][scoop-xrtools], [AUR][aur-alcom], or [WinGet][winget-alcom].
|
||||
|
||||
If you want, you may download the HEAD build from [GitHub Actions][alcom-nightly]
|
||||
|
||||
[alcom-releases]: https://github.com/anatawa12/vrc-get/releases?q=gui-v0
|
||||
[alcom-site]: https://vrc-get.anatawa12.com/alcom/
|
||||
[alcom-nightly]: https://github.com/vrc-get/vrc-get/actions/workflows/ci-gui.yml?query=branch%3Amaster
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
ALCOM runs on macOS, Windows, and Linux.
|
||||
|
||||
We support modern versions of the platforms.
|
||||
Basically, we support the versions that the platform vendor supports.
|
||||
|
||||
This support policy is to describe how my limited development resources use so it's welcome
|
||||
to pull requests that ports ALCOM to an older version of OSes.
|
||||
However, I won't maintain the backports so may break at any moment, I'll try to not break as possible though.
|
||||
|
||||
Here are detailed version support policies for each platform:
|
||||
Version numbers shown here are as of the writing (Dec 2024), so might be outdated.
|
||||
|
||||
- macOS: We support the latest version of macOS that is available for non-vintage and non-obsoleted Macs.\
|
||||
This means currently we support macOS 13 (Ventura) or later.
|
||||
On macOS, we use WKWebView, which is built-in to macOS, so no additional requirements are needed.
|
||||
- Windows: We support the latest version of Windows that is supported as mainstream by Microsoft.\
|
||||
This means currently we support Windows 10 21H2 or later and Windows 11 23H2 or later.
|
||||
On windows, we use WebView2 so WebView2 should also be updated to supported versions.
|
||||
Currently, WebView2 with Edge 130 or later is supported.
|
||||
- Linux: Linux is not well-supported, Linux support is best-effort by the community.\
|
||||
No maintainer is using Linux as a primary platform, so we can't guarantee the quality of the Linux version.\
|
||||
No specific version is guaranteed to work, but we will try to fix issues with your help.\
|
||||
Basically, modern webkit2gtk 4.1 is required to run ALCOM since we use modern web features.
|
||||
|
||||
## Requirements (building)
|
||||
|
||||
To build ALCOM, you need to have the following installed:
|
||||
|
||||
- [Node.js] LTS — to build the web part of the project
|
||||
- [Node.js] >=20 supported — to build the web part of the project
|
||||
- [npm] v10 — to install the dependencies of the web part (bundled with node.js so no extra attention needed in most case)
|
||||
- [cargo] latest — to build the most part of the project
|
||||
- [cargo-about] latest — to generate the licenses json (for development, not required but required for building release binary)
|
||||
- [.NET SDK] v8 — to build vrc-get-litedb crate
|
||||
- And other requirements for tauri, see [tauri requirements](https://v2.tauri.app/start/prerequisites/#system-dependencies)
|
||||
|
||||
Please note that ALCOM requires the latest version of cargo and cargo-about at that time.
|
||||
We update the required version of cargo and cargo-about without notice.
|
||||
Therefore, you may need to update them before building the project.
|
||||
Please note that ALCOM requires the latest version of rust toolchain at that time.
|
||||
We update the required version of cargo without notice.
|
||||
Therefore, It's recommended to update rust toolchain before building the project.
|
||||
|
||||
[Node.js]: https://nodejs.org/en
|
||||
[npm]: https://www.npmjs.com
|
||||
[cargo]: https://doc.rust-lang.org/cargo/
|
||||
[cargo-about]: https://github.com/EmbarkStudios/cargo-about
|
||||
[.NET SDK]: https://dotnet.microsoft.com/download
|
||||
|
||||
## Building
|
||||
|
||||
To build the project, run the following command:
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
cargo xtask build-alcom --release
|
||||
```
|
||||
|
||||
This command builds the main ALCOM executable for the current platform.
|
||||
For cross-compilation, add the `--target` command-line parameter.
|
||||
The executable will be created in the `target/release` directory.
|
||||
|
||||
There are a few build options available when building ALCOM.
|
||||
Most notably, you can disable the self-updater with the `--no-self-updater` option.
|
||||
Note that this does not disable update checks.
|
||||
ALCOM will show a message when a newer release is available instead of offering a self-update.
|
||||
|
||||
Directly distributing the executable may be suitable for some environments, but we also provide bundled distributions.
|
||||
To bundle ALCOM, run the following command after building it.
|
||||
|
||||
```bash
|
||||
cargo xtask bundle-alcom --release --bundles <bundles>
|
||||
```
|
||||
|
||||
Check `--help` for the list of supported bundle types.
|
||||
|
||||
## Development
|
||||
|
||||
ALCOM is currently based on tauri and next.js.
|
||||
|
|
@ -50,3 +118,7 @@ Run `npm run tauri dev` to start the development server and gui.
|
|||
## Contribution
|
||||
|
||||
For how to contribute localization to ALCOM (vrc-get-gui): [CONTRIBUTING.md](CONTRIBUTING.md) (**Please read [../CONTRIBUTING.md#configuration-requirements](../CONTRIBUTING.md#configuration-requirements) first before you read [CONTRIBUTING.md](CONTRIBUTING.md)!**)
|
||||
|
||||
## License
|
||||
|
||||
ALCOM is licensed under the MIT License. See [LICENSE](../LICENSE) for more information.
|
||||
|
|
|
|||
|
|
@ -6,52 +6,3 @@ beforeBuildCommand = "npm run build"
|
|||
beforeDevCommand = "npm run dev"
|
||||
devUrl = "http://localhost:3030"
|
||||
frontendDist = "out"
|
||||
|
||||
[bundle]
|
||||
active = true
|
||||
targets = [
|
||||
"appimage",
|
||||
"nsis", #-setup.exe
|
||||
"app", # needs for dmg
|
||||
"dmg",
|
||||
]
|
||||
longDescription = "ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri."
|
||||
shortDescription = "ALCOM - Alternative Creator Companion"
|
||||
category = "DeveloperTool"
|
||||
copyright = "(c) anatawa12 and other contributors"
|
||||
|
||||
externalBin = []
|
||||
icon = [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
]
|
||||
resources = []
|
||||
publisher = "anatawa12"
|
||||
|
||||
createUpdaterArtifacts = "v1Compatible" # remove if ci # we do not generate updater artifacts in CI
|
||||
|
||||
[bundle.linux.deb]
|
||||
depends = []
|
||||
|
||||
[bundle.macOS]
|
||||
exceptionDomain = ""
|
||||
frameworks = []
|
||||
providerShortName = "anatawa12"
|
||||
|
||||
[bundle.windows]
|
||||
nsis.template = "installer.nsi"
|
||||
|
||||
# signing
|
||||
certificateThumbprint = "0D17F6395EC64A2B1D341BB7AC5B3163EB148BB7"
|
||||
timestampUrl = "http://ts.ssl.com"
|
||||
digestAlgorithm = "sha256"
|
||||
tsp = true
|
||||
|
||||
[plugins.updater]
|
||||
endpoints = []
|
||||
pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkyMjAzMkU2Q0ZGQjQ0MjYKUldRbVJQdlA1aklna2d2NnRoM3ZsT3lzWEQ3MC9zTGpaWVR4NGdQOXR0UGJaOHBlY2xCcFY5bHcK"
|
||||
|
||||
[app.security]
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { SideBar } from "@/components/SideBar";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<SideBar className={"flex-grow-0"} />
|
||||
<div className={"h-screen flex-grow overflow-hidden flex p-4"}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { SearchBox } from "@/components/SearchBox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type LogEntry, type LogLevel, commands } from "@/lib/bindings";
|
||||
import { isFindKey, useDocumentEvent } from "@/lib/events";
|
||||
import globalInfo from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
|
||||
export const LogListCard = memo(function LogListCard({
|
||||
logEntry,
|
||||
}: {
|
||||
logEntry: LogEntry[];
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [shouldShowLogLevel, setShouldShowLogLevel] = useState<LogLevel[]>([
|
||||
"Info",
|
||||
"Warn",
|
||||
"Error",
|
||||
]);
|
||||
|
||||
const logsShown = useMemo(
|
||||
() =>
|
||||
logEntry.filter(
|
||||
(log) =>
|
||||
log.message.toLowerCase().includes(search?.toLowerCase() ?? "") &&
|
||||
shouldShowLogLevel.includes(log.level),
|
||||
),
|
||||
[logEntry, search, shouldShowLogLevel],
|
||||
);
|
||||
|
||||
const TABLE_HEAD = ["logs:time", "logs:level", "logs:message"];
|
||||
|
||||
return (
|
||||
<Card className="flex-grow flex-shrink flex shadow-none w-full">
|
||||
<CardContent className="w-full p-2 flex flex-col gap-2">
|
||||
<ManageLogsHeading
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>
|
||||
<ScrollableCardTable>
|
||||
<thead>
|
||||
<tr>
|
||||
{TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsShown.map((row) => (
|
||||
<tr key={row.time} className="even:bg-secondary/30">
|
||||
<LogRow log={row} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
function LogLevelMenuItem({
|
||||
logLevel,
|
||||
className,
|
||||
shouldShowLogLevel,
|
||||
setShouldShowLogLevel,
|
||||
}: {
|
||||
logLevel: LogLevel;
|
||||
className?: string;
|
||||
shouldShowLogLevel: LogLevel[];
|
||||
setShouldShowLogLevel: React.Dispatch<React.SetStateAction<LogLevel[]>>;
|
||||
}) {
|
||||
const selected = shouldShowLogLevel.includes(logLevel);
|
||||
const onChange = () => {
|
||||
if (selected) {
|
||||
setShouldShowLogLevel((prev) =>
|
||||
prev.filter((logLevelFilter) => logLevelFilter !== logLevel),
|
||||
);
|
||||
} else {
|
||||
setShouldShowLogLevel((prev) => [...prev, logLevel]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="p-0"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer items-center gap-2 p-2 whitespace-normal"
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onChange}
|
||||
className="hover:before:content-none"
|
||||
/>
|
||||
<p className={className}>{logLevel}</p>
|
||||
</label>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageLogsHeading({
|
||||
search,
|
||||
setSearch,
|
||||
shouldShowLogLevel,
|
||||
setShouldShowLogLevel,
|
||||
}: {
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
shouldShowLogLevel: LogLevel[];
|
||||
setShouldShowLogLevel: React.Dispatch<React.SetStateAction<LogLevel[]>>;
|
||||
}) {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useDocumentEvent(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (isFindKey(e)) {
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-wrap flex-shrink-0 flex-grow-0 flex-row gap-2 items-center"
|
||||
}
|
||||
>
|
||||
<SearchBox
|
||||
className={"w-max flex-grow"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
ref={searchRef}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className={"flex-shrink-0 p-3"}>
|
||||
{tc("logs:manage:select logs level")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Info"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Warn"
|
||||
className="text-warning"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Error"
|
||||
className="text-destructive"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Debug"
|
||||
className="text-info"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>
|
||||
{/* Currently no trace level logs will be passed to frontend */}
|
||||
{/*<LogLevelMenuItem
|
||||
logLevel="Trace"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>*/}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
commands.utilOpen(
|
||||
`${globalInfo.vpmHomeFolder}/vrc-get/gui-logs`,
|
||||
"ErrorIfNotExists",
|
||||
)
|
||||
}
|
||||
>
|
||||
{tc("settings:button:open logs")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LogRow = memo(function LogRow({
|
||||
log,
|
||||
}: {
|
||||
log: LogEntry;
|
||||
}) {
|
||||
const cellClass = "p-2.5";
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getFontColorClass = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case "Info":
|
||||
return "";
|
||||
case "Warn":
|
||||
return "text-warning";
|
||||
case "Error":
|
||||
return "text-destructive";
|
||||
case "Debug":
|
||||
return "text-info";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const fontColorClass = getFontColorClass(log.level);
|
||||
const typeIconClass = `${fontColorClass} w-5 h-5`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className={`${cellClass} min-w-32 w-32`}>{formatDate(log.time)}</td>
|
||||
<td className={`${cellClass} min-w-28 w-28`}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex items-center">
|
||||
{log.level === "Info" ? (
|
||||
<Info className={typeIconClass} />
|
||||
) : log.level === "Warn" ? (
|
||||
<OctagonAlert className={typeIconClass} />
|
||||
) : log.level === "Error" ? (
|
||||
<CircleX className={typeIconClass} />
|
||||
) : log.level === "Debug" ? (
|
||||
<BugOff className={typeIconClass} />
|
||||
) : (
|
||||
<Info className={typeIconClass} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className={`font-normal ${fontColorClass}`}>{log.level}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${cellClass} min-w-32 w-32`}>{log.message}</td>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import type { LogEntry } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { useTauriListen } from "@/lib/use-tauri-listen";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { LogListCard } from "./log-list-card";
|
||||
|
||||
export default function Page() {
|
||||
const [logEntries, setLogEntries] = React.useState<LogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
commands
|
||||
.utilGetLogEntries()
|
||||
.then((list) => setLogEntries([...list].reverse()));
|
||||
}, []);
|
||||
|
||||
useTauriListen<LogEntry>(
|
||||
"log",
|
||||
useCallback((event) => {
|
||||
setLogEntries((entries) => {
|
||||
const entry = event.payload as LogEntry;
|
||||
return [entry, ...entries];
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar className={"flex-shrink-0"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
|
||||
{tc("logs")}
|
||||
</p>
|
||||
<div className={"flex-grow"} />
|
||||
</HNavBar>
|
||||
<main className="flex-shrink overflow-hidden flex w-full">
|
||||
<LogListCard logEntry={logEntries} />
|
||||
</main>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TauriUserRepository } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import { useTauriListen } from "@/lib/use-tauri-listen";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, CircleX } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { HeadingPageName } from "../tab-selector";
|
||||
import { useAddRepository } from "./use-add-repository";
|
||||
import { useImportRepositories } from "./use-import-repositories";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PageBody() {
|
||||
const result = useQuery({
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
});
|
||||
|
||||
const addRepositoryInfo = useAddRepository({
|
||||
refetch: () => result.refetch(),
|
||||
});
|
||||
|
||||
const importRepositoryInfo = useImportRepositories({
|
||||
refetch: () => result.refetch(),
|
||||
});
|
||||
|
||||
const [exportRepositoriesRaw, exportDialog] = useFilePickerFunction(
|
||||
commands.environmentExportRepositories,
|
||||
);
|
||||
|
||||
const exportRepositories = useCallback(async () => {
|
||||
try {
|
||||
await exportRepositoriesRaw();
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
}
|
||||
}, [exportRepositoriesRaw]);
|
||||
|
||||
const hiddenUserRepos = useMemo(
|
||||
() => new Set(result.data?.hidden_user_repositories),
|
||||
[result],
|
||||
);
|
||||
|
||||
async function removeRepository(id: string) {
|
||||
try {
|
||||
await commands.environmentRemoveRepository(id);
|
||||
await result.refetch();
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const addRepository = addRepositoryInfo.addRepository;
|
||||
const processDeepLink = useCallback(
|
||||
async function processDeepLink() {
|
||||
const data = await commands.deepLinkTakeAddRepository();
|
||||
if (data == null) return;
|
||||
await addRepository(data.url, data.headers);
|
||||
},
|
||||
[addRepository],
|
||||
);
|
||||
|
||||
useTauriListen<null>(
|
||||
"deep-link-add-repository",
|
||||
useCallback(
|
||||
(_) => {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
processDeepLink();
|
||||
},
|
||||
[processDeepLink],
|
||||
),
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to do on mount
|
||||
useEffect(() => {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
processDeepLink();
|
||||
// Only for initial load
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar className={"flex-shrink-0"}>
|
||||
<HeadingPageName pageType={"/packages/repositories"} />
|
||||
<div className={"flex-grow"} />
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<Button
|
||||
className={"rounded-r-none"}
|
||||
onClick={addRepositoryInfo.openAddDialog}
|
||||
>
|
||||
{tc("vpm repositories:button:add repository")}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={importRepositoryInfo.startImportingRepositories}
|
||||
>
|
||||
{tc("vpm repositories:button:import repositories")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportRepositories}>
|
||||
{tc("vpm repositories:button:export repositories")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HNavBar>
|
||||
<ScrollableCardTable>
|
||||
<RepositoryTableBody
|
||||
userRepos={result.data?.user_repositories || []}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
removeRepository={removeRepository}
|
||||
refetch={() => result.refetch()}
|
||||
/>
|
||||
</ScrollableCardTable>
|
||||
{addRepositoryInfo.dialog}
|
||||
{importRepositoryInfo.dialog}
|
||||
{exportDialog}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryTableBody({
|
||||
userRepos,
|
||||
hiddenUserRepos,
|
||||
removeRepository,
|
||||
refetch,
|
||||
}: {
|
||||
userRepos: TauriUserRepository[];
|
||||
hiddenUserRepos: Set<string>;
|
||||
removeRepository: (id: string) => void;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const TABLE_HEAD = [
|
||||
"", // checkbox
|
||||
"general:name",
|
||||
"vpm repositories:url",
|
||||
"", // actions
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<thead>
|
||||
<tr>
|
||||
{TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRepos.map((repo) => (
|
||||
<RepositoryRow
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
remove={() => removeRepository(repo.id)}
|
||||
refetch={refetch}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryRow({
|
||||
repo,
|
||||
hiddenUserRepos,
|
||||
remove,
|
||||
refetch,
|
||||
}: {
|
||||
repo: TauriUserRepository;
|
||||
hiddenUserRepos: Set<string>;
|
||||
remove: () => void;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const cellClass = "p-2.5";
|
||||
const id = useId();
|
||||
|
||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
||||
|
||||
const selected = !hiddenUserRepos.has(repo.id);
|
||||
const onChange = () => {
|
||||
if (selected) {
|
||||
commands.environmentHideRepository(repo.id).then(refetch);
|
||||
} else {
|
||||
commands.environmentShowRepository(repo.id).then(refetch);
|
||||
}
|
||||
};
|
||||
|
||||
let dialog: React.ReactNode;
|
||||
if (removeDialogOpen) {
|
||||
dialog = (
|
||||
<DialogOpen>
|
||||
<DialogTitle>{tc("vpm repositories:remove repository")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p className={"whitespace-normal font-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm remove description", {
|
||||
name: repo.display_name,
|
||||
})}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRemoveDialogOpen(false)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
remove();
|
||||
setRemoveDialogOpen(false);
|
||||
}}
|
||||
className={"ml-2"}
|
||||
>
|
||||
{tc("vpm repositories:remove repository")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogOpen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="even:bg-secondary/30">
|
||||
<td className={cellClass}>
|
||||
<Checkbox id={id} checked={selected} onCheckedChange={onChange} />
|
||||
</td>
|
||||
<td className={cellClass}>
|
||||
<label htmlFor={id}>
|
||||
<p className="font-normal">{repo.display_name}</p>
|
||||
</label>
|
||||
</td>
|
||||
<td className={cellClass}>
|
||||
<p className="font-normal">{repo.url}</p>
|
||||
</td>
|
||||
<td className={`${cellClass} w-0`}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setRemoveDialogOpen(true)}
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<CircleX className={"size-5 text-destructive"} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tc("vpm repositories:remove repository")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
{dialog}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriDownloadRepository,
|
||||
TauriRepositoryDescriptor,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { callAsyncCommand } from "@/lib/call-async-command";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import type React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
type ParsedRepositories = {
|
||||
repositories: TauriRepositoryDescriptor[];
|
||||
unparsable_lines: string[];
|
||||
};
|
||||
|
||||
type State =
|
||||
| {
|
||||
type: "normal";
|
||||
}
|
||||
| {
|
||||
type: "confirmingRepositories";
|
||||
pickResult: ParsedRepositories;
|
||||
}
|
||||
| {
|
||||
type: "loadingRepositories";
|
||||
totalCount: number;
|
||||
downloaded: number;
|
||||
cancel: () => void;
|
||||
}
|
||||
| {
|
||||
type: "confirmingPackages";
|
||||
repositories: [TauriRepositoryDescriptor, TauriDownloadRepository][];
|
||||
}
|
||||
| {
|
||||
type: "addingRepositories";
|
||||
};
|
||||
|
||||
interface AddRepository {
|
||||
dialog: React.ReactNode;
|
||||
startImportingRepositories: () => void;
|
||||
}
|
||||
|
||||
export function useImportRepositories({
|
||||
refetch,
|
||||
}: {
|
||||
refetch: () => void;
|
||||
}): AddRepository {
|
||||
const [state, setState] = useState<State>({ type: "normal" });
|
||||
const [importRepositoryPick, pickDialog] = useFilePickerFunction(
|
||||
commands.environmentImportRepositoryPick,
|
||||
);
|
||||
|
||||
function cancel() {
|
||||
if ("cancel" in state) state.cancel();
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
|
||||
const startImportingRepositories = useCallback(
|
||||
async function startImportingRepositories() {
|
||||
try {
|
||||
const pickResult = await importRepositoryPick();
|
||||
switch (pickResult.type) {
|
||||
case "NoFilePicked":
|
||||
// no-op
|
||||
return;
|
||||
case "ParsedRepositories":
|
||||
// continue
|
||||
break;
|
||||
default:
|
||||
assertNever(pickResult, "pickResult");
|
||||
}
|
||||
console.log("confirmingRepositories", pickResult);
|
||||
setState({ type: "confirmingRepositories", pickResult });
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
},
|
||||
[importRepositoryPick],
|
||||
);
|
||||
|
||||
const downloadRepositories = useCallback(async function downloadRepositories(
|
||||
repositories: TauriRepositoryDescriptor[],
|
||||
) {
|
||||
try {
|
||||
const totalCount = repositories.length;
|
||||
const [cancel, resultPromise] = callAsyncCommand(
|
||||
commands.environmentImportDownloadRepositories,
|
||||
[repositories],
|
||||
(downloaded) => {
|
||||
setState({
|
||||
type: "loadingRepositories",
|
||||
totalCount,
|
||||
downloaded,
|
||||
cancel,
|
||||
});
|
||||
},
|
||||
);
|
||||
setState({
|
||||
type: "loadingRepositories",
|
||||
totalCount,
|
||||
downloaded: 0,
|
||||
cancel,
|
||||
});
|
||||
const result = await resultPromise;
|
||||
if (result === "cancelled") {
|
||||
return;
|
||||
}
|
||||
setState({ type: "confirmingPackages", repositories: result });
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRepositories = useCallback(
|
||||
async function addRepositories(repositories: TauriRepositoryDescriptor[]) {
|
||||
try {
|
||||
setState({ type: "addingRepositories" });
|
||||
await commands.environmentImportAddRepositories(repositories);
|
||||
toastSuccess(tt("vpm repositories:toast:repositories added"));
|
||||
refetch();
|
||||
setState({ type: "normal" });
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
},
|
||||
[refetch],
|
||||
);
|
||||
|
||||
let dialogBody: React.ReactNode;
|
||||
switch (state.type) {
|
||||
case "normal":
|
||||
dialogBody = null;
|
||||
break;
|
||||
case "confirmingRepositories":
|
||||
dialogBody = (
|
||||
<ConfirmingRepositoryList
|
||||
pickResult={state.pickResult}
|
||||
cancel={cancel}
|
||||
importRepositories={downloadRepositories}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "loadingRepositories":
|
||||
dialogBody = (
|
||||
<LoadingRepositories
|
||||
cancel={cancel}
|
||||
downloaded={state.downloaded}
|
||||
totalCount={state.totalCount}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "confirmingPackages":
|
||||
dialogBody = (
|
||||
<ConfirmingPackages
|
||||
repositories={state.repositories}
|
||||
cancel={cancel}
|
||||
addRepositories={addRepositories}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "addingRepositories":
|
||||
dialogBody = <AddingRepositories />;
|
||||
break;
|
||||
default:
|
||||
assertNever(state, "state");
|
||||
}
|
||||
|
||||
const confirmDialog = dialogBody ? (
|
||||
<DialogOpen>
|
||||
<DialogTitle>
|
||||
{tc("vpm repositories:dialog:import repositories")}
|
||||
</DialogTitle>
|
||||
{dialogBody}
|
||||
</DialogOpen>
|
||||
) : null;
|
||||
|
||||
return {
|
||||
dialog: (
|
||||
<>
|
||||
{pickDialog}
|
||||
{confirmDialog}
|
||||
</>
|
||||
),
|
||||
startImportingRepositories,
|
||||
};
|
||||
}
|
||||
|
||||
function shortRepositoryDescription(
|
||||
repo: TauriRepositoryDescriptor,
|
||||
): React.ReactNode {
|
||||
if (Object.keys(repo.headers).length > 0) {
|
||||
return tc("vpm repositories:dialog:repository with headers", {
|
||||
repoUrl: repo.url,
|
||||
});
|
||||
}
|
||||
return repo.url;
|
||||
}
|
||||
|
||||
function ConfirmingRepositoryList({
|
||||
pickResult,
|
||||
cancel,
|
||||
importRepositories,
|
||||
}: {
|
||||
pickResult: ParsedRepositories;
|
||||
cancel: () => void;
|
||||
importRepositories: (repositories: TauriRepositoryDescriptor[]) => void;
|
||||
}) {
|
||||
const onContinue = useCallback(
|
||||
async function onContinue() {
|
||||
importRepositories(pickResult.repositories);
|
||||
},
|
||||
[importRepositories, pickResult.repositories],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
|
||||
<p className={"font-normal whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm repository list")}
|
||||
</p>
|
||||
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{pickResult.repositories.map((info) => (
|
||||
<li key={info.url}>{shortRepositoryDescription(info)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{pickResult.unparsable_lines.length > 0 && (
|
||||
<>
|
||||
<p className={"font-normal whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:unparsable lines list")}
|
||||
</p>
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{pickResult.unparsable_lines.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: unchanged
|
||||
<li key={idx} className={"whitespace-pre"}>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
<Button onClick={onContinue}>
|
||||
{tc("vpm repositories:dialog:button:continue importing repositories")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRepositories({
|
||||
cancel,
|
||||
downloaded,
|
||||
totalCount,
|
||||
}: {
|
||||
cancel: () => void;
|
||||
downloaded: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p>{tc("vpm repositories:dialog:downloading repositories...")}</p>
|
||||
<Progress value={downloaded} max={totalCount} />
|
||||
<div className={"text-center"}>
|
||||
{tc("vpm repositories:dialog:downloaded n/m", {
|
||||
downloaded,
|
||||
totalCount,
|
||||
})}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmingPackages({
|
||||
repositories,
|
||||
cancel,
|
||||
addRepositories,
|
||||
}: {
|
||||
repositories: [TauriRepositoryDescriptor, TauriDownloadRepository][];
|
||||
cancel: () => void;
|
||||
addRepositories: (repositories: TauriRepositoryDescriptor[]) => void;
|
||||
}) {
|
||||
const add = useCallback(
|
||||
async function add() {
|
||||
addRepositories(
|
||||
repositories
|
||||
.filter(([_, download]) => download.type === "Success")
|
||||
.map(([repo, _]) => repo),
|
||||
);
|
||||
},
|
||||
[addRepositories, repositories],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"font-normal"}>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm packages list")}
|
||||
</p>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="max-h-[50vh] overflow-y-auto w-full"
|
||||
>
|
||||
{repositories.map(([repo, download]) => {
|
||||
let error: boolean;
|
||||
let content: React.ReactNode;
|
||||
switch (download.type) {
|
||||
case "BadUrl":
|
||||
throw new Error("BadUrl should not be here");
|
||||
case "Duplicated":
|
||||
error = true;
|
||||
content = tc(
|
||||
"vpm repositories:dialog:download error:duplicated",
|
||||
);
|
||||
break;
|
||||
case "DownloadError":
|
||||
error = true;
|
||||
content = tc(
|
||||
"vpm repositories:dialog:download error:download error",
|
||||
);
|
||||
break;
|
||||
case "Success":
|
||||
error = false;
|
||||
content = (
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{download.value.packages.map((info, idx) => (
|
||||
<li key={info.name}>{info.display_name ?? info.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
assertNever(download, "download");
|
||||
}
|
||||
const destrucive = error ? "text-destructive" : "";
|
||||
return (
|
||||
<AccordionItem value={repo.url} key={repo.url}>
|
||||
<AccordionTrigger className={`${destrucive} py-2 text-base`}>
|
||||
{shortRepositoryDescription(repo)}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className={destrucive}>
|
||||
{content}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
<Button onClick={add} className={"ml-2"}>
|
||||
{tc("vpm repositories:button:add repositories")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddingRepositories() {
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
|
||||
</DialogDescription>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { tc } from "@/lib/i18n";
|
||||
import Link from "next/link";
|
||||
|
||||
type PageType = "/packages/user-packages" | "/packages/repositories";
|
||||
|
||||
export function HeadingPageName({
|
||||
pageType,
|
||||
}: {
|
||||
pageType: PageType;
|
||||
}) {
|
||||
return (
|
||||
<div className={"-ml-2"}>
|
||||
<div
|
||||
className={"grid grid-cols-2 gap-1.5 bg-secondary p-1 -m-1 rounded-md"}
|
||||
>
|
||||
<HeadingButton
|
||||
currentPage={pageType}
|
||||
targetPage={"/packages/repositories"}
|
||||
>
|
||||
{tc("packages:community repositories")}
|
||||
</HeadingButton>
|
||||
<HeadingButton
|
||||
currentPage={pageType}
|
||||
targetPage={"/packages/user-packages"}
|
||||
>
|
||||
{tc("packages:user packages")}
|
||||
</HeadingButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingButton({
|
||||
currentPage,
|
||||
targetPage,
|
||||
children,
|
||||
}: {
|
||||
currentPage: PageType;
|
||||
targetPage: PageType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const button =
|
||||
"cursor-pointer py-1.5 font-bold flex-grow-0 hover:bg-background rounded-sm text-center p-2";
|
||||
|
||||
if (currentPage === targetPage) {
|
||||
return <div className={`${button} bg-background`}>{children}</div>;
|
||||
} else {
|
||||
return (
|
||||
<Link href={targetPage} className={button}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
import { VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriProjectDirCheckResult,
|
||||
TauriProjectTemplate,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { pathSeparator } from "@/lib/os";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type CreateProjectstate =
|
||||
| "loadingInitialInformation"
|
||||
| "enteringInformation"
|
||||
| "creating";
|
||||
|
||||
export function CreateProject({
|
||||
close,
|
||||
refetch,
|
||||
}: {
|
||||
close?: () => void;
|
||||
refetch?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [state, setState] = useState<CreateProjectstate>(
|
||||
"loadingInitialInformation",
|
||||
);
|
||||
const [projectNameCheckState, setProjectNameCheckState] = useState<
|
||||
"checking" | TauriProjectDirCheckResult
|
||||
>("Ok");
|
||||
|
||||
type CustomTemplate = TauriProjectTemplate & { type: "Custom" };
|
||||
|
||||
const templateUnityVersions = [
|
||||
"2022.3.22f1",
|
||||
"2022.3.6f1",
|
||||
"2019.4.31f1",
|
||||
] as const;
|
||||
const latestUnityVersion = templateUnityVersions[0];
|
||||
|
||||
type TemplateType = "avatars" | "worlds" | "custom";
|
||||
type TemplateUnityVersion = (typeof templateUnityVersions)[number];
|
||||
|
||||
const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>([]);
|
||||
|
||||
const [templateType, setTemplateType] = useState<TemplateType>("avatars");
|
||||
const [unityVersion, setUnityVersion] =
|
||||
useState<TemplateUnityVersion>(latestUnityVersion);
|
||||
const [customTemplate, setCustomTemplate] = useState<CustomTemplate>();
|
||||
|
||||
function onCustomTemplateChange(value: string) {
|
||||
const newCustomTemplate: CustomTemplate = {
|
||||
type: "Custom",
|
||||
name: value,
|
||||
};
|
||||
setCustomTemplate(newCustomTemplate);
|
||||
}
|
||||
|
||||
const [projectNameRaw, setProjectName] = useState("New Project");
|
||||
const projectName = projectNameRaw.trim();
|
||||
const [projectLocation, setProjectLocation] = useState("");
|
||||
const projectNameDebounced = useDebounce(projectName, 500);
|
||||
|
||||
const [pickProjectDefaultPath, dialog] = useFilePickerFunction(
|
||||
commands.environmentPickProjectDefaultPath,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const information =
|
||||
await commands.environmentProjectCreationInformation();
|
||||
const customTemplates = information.templates.filter(
|
||||
(template): template is CustomTemplate => template.type === "Custom",
|
||||
);
|
||||
setCustomTemplates(customTemplates);
|
||||
setCustomTemplate(customTemplates[0]);
|
||||
setProjectLocation(information.default_path);
|
||||
setState("enteringInformation");
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setProjectNameCheckState("checking");
|
||||
const result = await commands.environmentCheckProjectName(
|
||||
projectLocation,
|
||||
projectNameDebounced,
|
||||
);
|
||||
if (canceled) return;
|
||||
setProjectNameCheckState(result);
|
||||
} catch (e) {
|
||||
console.error("Error checking project name", e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [projectNameDebounced, projectLocation]);
|
||||
|
||||
const selectProjectDefaultFolder = async () => {
|
||||
try {
|
||||
const result = await pickProjectDefaultPath();
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
setProjectLocation(result.new_path);
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const createProject = async () => {
|
||||
try {
|
||||
setState("creating");
|
||||
let template: TauriProjectTemplate;
|
||||
switch (templateType) {
|
||||
case "avatars":
|
||||
case "worlds":
|
||||
template = {
|
||||
type: "Builtin",
|
||||
id: `${templateType}-${unityVersion}`,
|
||||
name: `${templateType}-${unityVersion}`,
|
||||
};
|
||||
break;
|
||||
case "custom":
|
||||
if (customTemplate === undefined)
|
||||
throw new Error("Custom template not selected");
|
||||
template = customTemplate;
|
||||
break;
|
||||
default:
|
||||
assertNever(templateType, "template type");
|
||||
}
|
||||
await commands.environmentCreateProject(
|
||||
projectLocation,
|
||||
projectName,
|
||||
template,
|
||||
);
|
||||
toastSuccess(tt("projects:toast:project created"));
|
||||
close?.();
|
||||
refetch?.();
|
||||
const projectPath = `${projectLocation}${pathSeparator()}${projectName}`;
|
||||
router.push(`/projects/manage?${new URLSearchParams({ projectPath })}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
close?.();
|
||||
}
|
||||
};
|
||||
|
||||
const checking =
|
||||
projectNameDebounced !== projectName ||
|
||||
projectNameCheckState === "checking";
|
||||
|
||||
let projectNameState: "Ok" | "warn" | "err";
|
||||
let projectNameCheck: React.ReactNode;
|
||||
|
||||
switch (projectNameCheckState) {
|
||||
case "Ok":
|
||||
projectNameCheck = tc("projects:hint:create project ready");
|
||||
projectNameState = "Ok";
|
||||
break;
|
||||
case "InvalidNameForFolderName":
|
||||
projectNameCheck = tc("projects:hint:invalid project name");
|
||||
projectNameState = "err";
|
||||
break;
|
||||
case "MayCompatibilityProblem":
|
||||
projectNameCheck = tc("projects:hint:warn symbol in project name");
|
||||
projectNameState = "warn";
|
||||
break;
|
||||
case "WideChar":
|
||||
projectNameCheck = tc(
|
||||
"projects:hint:warn multibyte char in project name",
|
||||
);
|
||||
projectNameState = "warn";
|
||||
break;
|
||||
case "AlreadyExists":
|
||||
projectNameCheck = tc("projects:hint:project already exists");
|
||||
projectNameState = "err";
|
||||
break;
|
||||
case "checking":
|
||||
projectNameCheck = <RefreshCw className={"w-5 h-5 animate-spin"} />;
|
||||
projectNameState = "Ok";
|
||||
break;
|
||||
default:
|
||||
assertNever(projectNameCheckState);
|
||||
}
|
||||
|
||||
let projectNameStateClass: React.ReactNode;
|
||||
switch (projectNameState) {
|
||||
case "Ok":
|
||||
projectNameStateClass = "text-success";
|
||||
break;
|
||||
case "warn":
|
||||
projectNameStateClass = "text-warning";
|
||||
break;
|
||||
case "err":
|
||||
projectNameStateClass = "text-destructive";
|
||||
}
|
||||
|
||||
if (checking)
|
||||
projectNameCheck = <RefreshCw className={"w-5 h-5 animate-spin"} />;
|
||||
|
||||
let dialogBody: React.ReactNode;
|
||||
|
||||
switch (state) {
|
||||
case "loadingInitialInformation":
|
||||
dialogBody = <RefreshCw className={"w-5 h-5 animate-spin"} />;
|
||||
break;
|
||||
case "enteringInformation": {
|
||||
const renderUnityVersion = (unityVersion: string) => {
|
||||
if (unityVersion === latestUnityVersion) {
|
||||
return (
|
||||
<>
|
||||
{unityVersion}{" "}
|
||||
<span className={"text-success"}>{tc("projects:latest")}</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return unityVersion;
|
||||
}
|
||||
};
|
||||
dialogBody = (
|
||||
<>
|
||||
<VStack>
|
||||
<div className={"flex gap-1"}>
|
||||
<div className={"flex items-center"}>
|
||||
<label>{tc("projects:template:type")}</label>
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={templateType}
|
||||
onValueChange={(value) =>
|
||||
setTemplateType(value as TemplateType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={"avatars"}>
|
||||
{tc("projects:type:avatars")}
|
||||
</SelectItem>
|
||||
<SelectItem value={"worlds"}>
|
||||
{tc("projects:type:worlds")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={"custom"}
|
||||
disabled={customTemplates.length === 0}
|
||||
>
|
||||
{tc("projects:type:custom")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{templateType !== "custom" ? (
|
||||
<div className={"flex gap-1"}>
|
||||
<div className={"flex items-center"}>
|
||||
<label>{tc("projects:template:unity version")}</label>
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={unityVersion}
|
||||
onValueChange={(value) =>
|
||||
setUnityVersion(value as TemplateUnityVersion)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateUnityVersions.map((unityVersion) => (
|
||||
<SelectItem value={unityVersion} key={unityVersion}>
|
||||
{renderUnityVersion(unityVersion)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"flex gap-1"}>
|
||||
<div className={"flex items-center"}>
|
||||
<label>{tc("projects:template")}</label>
|
||||
</div>
|
||||
<Select
|
||||
value={customTemplate?.name}
|
||||
onValueChange={onCustomTemplateChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{customTemplates.map((template) => (
|
||||
<SelectItem value={template.name} key={template.name}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={projectNameRaw}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
<div className={"flex gap-1 items-center"}>
|
||||
<Input className="flex-auto" value={projectLocation} disabled />
|
||||
<Button
|
||||
className="flex-none px-4"
|
||||
onClick={selectProjectDefaultFolder}
|
||||
>
|
||||
{tc("general:button:select")}
|
||||
</Button>
|
||||
</div>
|
||||
<small className={"whitespace-normal"}>
|
||||
{tc(
|
||||
"projects:hint:path of creating project",
|
||||
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
|
||||
{
|
||||
components: {
|
||||
path: (
|
||||
<span
|
||||
className={
|
||||
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
<small className={`whitespace-normal ${projectNameStateClass}`}>
|
||||
{projectNameCheck}
|
||||
</small>
|
||||
</VStack>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "creating":
|
||||
dialogBody = (
|
||||
<>
|
||||
<RefreshCw className={"w-5 h-5 animate-spin"} />
|
||||
<p>{tc("projects:creating project...")}</p>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogOpen>
|
||||
<DialogTitle>{tc("projects:create new project")}</DialogTitle>
|
||||
<DialogDescription>{dialogBody}</DialogDescription>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={close} disabled={state === "creating"}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createProject}
|
||||
disabled={
|
||||
state === "creating" || checking || projectNameState === "err"
|
||||
}
|
||||
>
|
||||
{tc("projects:button:create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{dialog}
|
||||
</DialogOpen>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,40 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ElementRef,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
interface PageContext {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const PageContext = createContext<PageContext>({
|
||||
isLoading: false,
|
||||
});
|
||||
PageContext.displayName = "PageContext";
|
||||
|
||||
export const PageContextProvider = PageContext.Provider;
|
||||
|
||||
export function usePageContext() {
|
||||
return useContext(PageContext);
|
||||
}
|
||||
|
||||
export const ButtonDisabledIfLoading = forwardRef<
|
||||
ElementRef<typeof Button>,
|
||||
ComponentProps<typeof Button>
|
||||
>(function ButtonDisabledIfLoading({ disabled, ...props }, ref) {
|
||||
const { isLoading } = usePageContext();
|
||||
return <Button disabled={isLoading || disabled} {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
export const CheckboxDisabledIfLoading = forwardRef<
|
||||
ElementRef<typeof Checkbox>,
|
||||
ComponentProps<typeof Checkbox>
|
||||
>(function CheckboxDisabledIfLoading({ disabled, ...props }, ref) {
|
||||
const { isLoading } = usePageContext();
|
||||
return <Checkbox disabled={isLoading || disabled} {...props} ref={ref} />;
|
||||
});
|
||||
|
|
@ -1,671 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
UnityArgumentsSettings,
|
||||
useUnityArgumentsSettings,
|
||||
} from "@/components/unity-arguments-settings";
|
||||
import { useBackupProjectModal } from "@/lib/backup-project";
|
||||
import type { TauriProjectDetails, TauriUnityVersions } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { nameFromPath } from "@/lib/os";
|
||||
import { useRemoveProjectModal } from "@/lib/remove-project";
|
||||
import { toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useOpenUnity } from "@/lib/use-open-unity";
|
||||
import { compareUnityVersionString } from "@/lib/version";
|
||||
import {
|
||||
type UseQueryResult,
|
||||
useQueries,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { ArrowLeft, ChevronDown } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
VRCSDK_PACKAGES,
|
||||
combinePackagesAndProjectDetails,
|
||||
} from "./collect-package-row-info";
|
||||
import { PackageListCard } from "./package-list-card";
|
||||
import { PageContextProvider } from "./page-context";
|
||||
import {
|
||||
useUnity2022Migration,
|
||||
useUnity2022PatchMigration,
|
||||
useUnityVersionChange,
|
||||
} from "./unity-migration";
|
||||
import { usePackageChangeDialog } from "./use-package-change";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const projectRemoveModal = useRemoveProjectModal({
|
||||
onRemoved: () => router.back(),
|
||||
});
|
||||
const backupProjectModal = useBackupProjectModal();
|
||||
|
||||
const projectPath = searchParams.get("projectPath") ?? "";
|
||||
const projectName = nameFromPath(projectPath);
|
||||
|
||||
// repositoriesInfo: list of repositories and their visibility
|
||||
// packagesResult: list of packages
|
||||
// detailsResult: project details including installed packages
|
||||
// unityVersionsResult: list of unity versions installed
|
||||
const [repositoriesInfo, packagesResult, detailsResult, unityVersionsResult] =
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["environmentPackages"],
|
||||
queryFn: commands.environmentPackages,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
queryFn: () => commands.projectDetails(projectPath),
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["environmentUnityVersions"],
|
||||
queryFn: () => commands.environmentUnityVersions(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [manualRefetching, setManualRefething] = useState<boolean>(false);
|
||||
|
||||
const packageRowsData = useMemo(() => {
|
||||
const packages = packagesResult.data ?? [];
|
||||
const details = detailsResult.data ?? null;
|
||||
const hiddenRepositories =
|
||||
repositoriesInfo.data?.hidden_user_repositories ?? [];
|
||||
const hideUserPackages =
|
||||
repositoriesInfo.data?.hide_local_user_packages ?? false;
|
||||
const definedRepositories = repositoriesInfo.data?.user_repositories ?? [];
|
||||
const showPrereleasePackages =
|
||||
repositoriesInfo.data?.show_prerelease_packages ?? false;
|
||||
return combinePackagesAndProjectDetails(
|
||||
packages,
|
||||
details,
|
||||
hiddenRepositories,
|
||||
hideUserPackages,
|
||||
definedRepositories,
|
||||
showPrereleasePackages,
|
||||
);
|
||||
}, [repositoriesInfo.data, packagesResult.data, detailsResult.data]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
try {
|
||||
setManualRefething(true);
|
||||
await commands.environmentRefetchPackages();
|
||||
packagesResult.refetch();
|
||||
detailsResult.refetch();
|
||||
repositoriesInfo.refetch();
|
||||
unityVersionsResult.refetch();
|
||||
} finally {
|
||||
setManualRefething(false);
|
||||
}
|
||||
}, [detailsResult, packagesResult, repositoriesInfo, unityVersionsResult]);
|
||||
|
||||
const onRefreshProject = useCallback(() => {
|
||||
detailsResult.refetch();
|
||||
packagesResult.refetch(); // package changes require package list to be refreshed
|
||||
}, [detailsResult, packagesResult]);
|
||||
|
||||
const packageChangeDialog = usePackageChangeDialog({
|
||||
projectPath,
|
||||
onRefreshProject,
|
||||
packageRowsData,
|
||||
existingPackages: detailsResult.data?.installed_packages,
|
||||
});
|
||||
|
||||
const unity2022Migration = useUnity2022Migration({
|
||||
projectPath,
|
||||
refresh: onRefresh,
|
||||
});
|
||||
|
||||
const unity2022PatchMigration = useUnity2022PatchMigration({
|
||||
projectPath,
|
||||
refresh: onRefresh,
|
||||
});
|
||||
|
||||
const onRefreshRepositories = useCallback(() => {
|
||||
repositoriesInfo.refetch();
|
||||
}, [repositoriesInfo]);
|
||||
|
||||
const onRemoveProject = useCallback(() => {
|
||||
projectRemoveModal.startRemove({
|
||||
path: projectPath,
|
||||
name: projectName,
|
||||
is_exists: true,
|
||||
});
|
||||
}, [projectName, projectPath, projectRemoveModal]);
|
||||
|
||||
const onBackupProject = useCallback(() => {
|
||||
backupProjectModal.startBackup({
|
||||
path: projectPath,
|
||||
name: projectName,
|
||||
});
|
||||
}, [backupProjectModal, projectName, projectPath]);
|
||||
|
||||
const onResolveRequest = useCallback(() => {
|
||||
packageChangeDialog.createChanges(
|
||||
{ type: "resolve" },
|
||||
commands.projectResolve(projectPath),
|
||||
);
|
||||
}, [packageChangeDialog, projectPath]);
|
||||
|
||||
const isLoading =
|
||||
packagesResult.isFetching ||
|
||||
detailsResult.isFetching ||
|
||||
repositoriesInfo.isFetching ||
|
||||
unityVersionsResult.isLoading ||
|
||||
packageChangeDialog.installingPackage ||
|
||||
manualRefetching;
|
||||
|
||||
console.log(`rerender: isloading: ${isLoading}`);
|
||||
|
||||
function checkIfMigrationTo2022Recommended(data: TauriProjectDetails) {
|
||||
if (data.unity == null) return false;
|
||||
// migrate if the project is using 2019 and has vrcsdk
|
||||
if (data.unity[0] !== 2019) return false;
|
||||
return data.installed_packages.some(([id, _]) =>
|
||||
VRCSDK_PACKAGES.includes(id),
|
||||
);
|
||||
}
|
||||
|
||||
function checkIf2022PatchMigrationRecommended(
|
||||
data: TauriProjectDetails,
|
||||
unityData: TauriUnityVersions,
|
||||
) {
|
||||
if (
|
||||
!data.installed_packages.some(([id, _]) => VRCSDK_PACKAGES.includes(id))
|
||||
)
|
||||
return false;
|
||||
|
||||
if (data.unity == null) return false;
|
||||
if (data.unity[0] !== 2022) return false;
|
||||
// unity patch is 2022.
|
||||
return data.unity_str !== unityData.recommended_version;
|
||||
}
|
||||
|
||||
const isResolveRecommended = detailsResult?.data?.should_resolve;
|
||||
const isMigrationTo2022Recommended =
|
||||
detailsResult.status === "success" &&
|
||||
checkIfMigrationTo2022Recommended(detailsResult.data);
|
||||
const is2022PatchMigrationRecommended =
|
||||
detailsResult.status === "success" &&
|
||||
unityVersionsResult.status === "success" &&
|
||||
checkIf2022PatchMigrationRecommended(
|
||||
detailsResult.data,
|
||||
unityVersionsResult.data,
|
||||
);
|
||||
|
||||
const pageContext = useMemo(() => ({ isLoading }), [isLoading]);
|
||||
|
||||
return (
|
||||
<PageContextProvider value={pageContext}>
|
||||
<VStack>
|
||||
<ProjectViewHeader
|
||||
className={"flex-shrink-0"}
|
||||
projectName={projectName}
|
||||
projectPath={projectPath}
|
||||
unityVersion={detailsResult.data?.unity_str ?? null}
|
||||
unityRevision={detailsResult.data?.unity_revision ?? null}
|
||||
onRemove={onRemoveProject}
|
||||
onBackup={onBackupProject}
|
||||
/>
|
||||
<Card
|
||||
className={"flex-shrink-0 p-2 flex flex-row flex-wrap items-center"}
|
||||
>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow flex-shrink overflow-hidden basis-52">
|
||||
{tc(
|
||||
"projects:manage:project location",
|
||||
{ path: projectPath },
|
||||
{
|
||||
components: {
|
||||
path: (
|
||||
<span
|
||||
className={
|
||||
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<div className={"flex-grow-0 flex-shrink-0 w-2"} />
|
||||
<div className="flex-grow-0 flex-shrink-0 flex flex-row items-center">
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink-0">
|
||||
{tc("projects:manage:unity version")}
|
||||
</p>
|
||||
<div className={"flex-grow-0 flex-shrink-0"}>
|
||||
<UnityVersionSelector
|
||||
disabled={isLoading}
|
||||
projectPath={projectPath}
|
||||
detailsResult={detailsResult}
|
||||
unityVersions={unityVersionsResult.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{isResolveRecommended && (
|
||||
<SuggestResolveProjectCard
|
||||
disabled={isLoading}
|
||||
onResolveRequested={onResolveRequest}
|
||||
/>
|
||||
)}
|
||||
{isMigrationTo2022Recommended && (
|
||||
<SuggestMigrateTo2022Card
|
||||
disabled={isLoading}
|
||||
onMigrateRequested={() => unity2022Migration.request({})}
|
||||
/>
|
||||
)}
|
||||
{is2022PatchMigrationRecommended && (
|
||||
<Suggest2022PatchMigrationCard
|
||||
disabled={isLoading}
|
||||
onMigrateRequested={() => unity2022PatchMigration.request({})}
|
||||
/>
|
||||
)}
|
||||
<main className="flex-shrink overflow-hidden flex w-full">
|
||||
<PackageListCard
|
||||
projectPath={projectPath}
|
||||
createChanges={packageChangeDialog.createChanges}
|
||||
packageRowsData={packageRowsData}
|
||||
repositoriesInfo={repositoriesInfo.data}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
/>
|
||||
</main>
|
||||
{packageChangeDialog.dialog}
|
||||
{unity2022Migration.dialog}
|
||||
{unity2022PatchMigration.dialog}
|
||||
{projectRemoveModal.dialog}
|
||||
{backupProjectModal.dialog}
|
||||
</VStack>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityVersionSelector({
|
||||
disabled,
|
||||
projectPath,
|
||||
detailsResult,
|
||||
unityVersions,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
projectPath: string;
|
||||
detailsResult: UseQueryResult<TauriProjectDetails>;
|
||||
unityVersions?: TauriUnityVersions;
|
||||
}) {
|
||||
const unityChangeVersion = useUnityVersionChange({
|
||||
projectPath,
|
||||
refresh: () => detailsResult.refetch(),
|
||||
});
|
||||
|
||||
const unityVersionNames = useMemo(() => {
|
||||
if (unityVersions == null) return null;
|
||||
const versionNames = [
|
||||
...new Set<string>(unityVersions.unity_paths.map(([, path]) => path)),
|
||||
];
|
||||
versionNames.sort((a, b) => compareUnityVersionString(b, a));
|
||||
return versionNames;
|
||||
}, [unityVersions]);
|
||||
|
||||
const onChange = useCallback(
|
||||
async (version: string) => {
|
||||
const detailsData = detailsResult.data;
|
||||
if (detailsData == null) return;
|
||||
const currentUnityVersion = detailsData.unity_str;
|
||||
if (currentUnityVersion == null) return;
|
||||
const isVRCProject = detailsData.installed_packages.some(([id, _]) =>
|
||||
VRCSDK_PACKAGES.includes(id),
|
||||
);
|
||||
unityChangeVersion.request({
|
||||
version,
|
||||
isVRCProject,
|
||||
currentUnityVersion,
|
||||
});
|
||||
},
|
||||
[detailsResult.data, unityChangeVersion],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={detailsResult.data?.unity_str ?? undefined}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{detailsResult.status === "success" ? (
|
||||
detailsResult.data.unity_str ?? "unknown"
|
||||
) : (
|
||||
<span className={"text-primary"}>Loading...</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{unityVersionNames == null ? (
|
||||
<SelectLabel>Loading...</SelectLabel>
|
||||
) : (
|
||||
unityVersionNames.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
{unityChangeVersion.dialog}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestResolveProjectCard({
|
||||
disabled,
|
||||
onResolveRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onResolveRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
|
||||
{tc("projects:manage:suggest resolve")}
|
||||
</p>
|
||||
<div className={"flex-grow flex-shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onResolveRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:resolve")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestMigrateTo2022Card({
|
||||
disabled,
|
||||
onMigrateRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onMigrateRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
|
||||
{tc("projects:manage:suggest unity migration")}
|
||||
</p>
|
||||
<div className={"flex-grow flex-shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onMigrateRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:unity migrate")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Suggest2022PatchMigrationCard({
|
||||
disabled,
|
||||
onMigrateRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onMigrateRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
|
||||
{tc("projects:manage:suggest unity patch migration")}
|
||||
</p>
|
||||
<div className={"flex-grow flex-shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onMigrateRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:unity migrate")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectViewHeader({
|
||||
className,
|
||||
projectName,
|
||||
projectPath,
|
||||
unityVersion,
|
||||
unityRevision,
|
||||
onRemove,
|
||||
onBackup,
|
||||
}: {
|
||||
className?: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
unityVersion: string | null;
|
||||
unityRevision: string | null;
|
||||
onRemove?: () => void;
|
||||
onBackup?: () => void;
|
||||
}) {
|
||||
const openUnity = useOpenUnity();
|
||||
const [openLaunchOptions, setOpenLaunchOptions] = useState<
|
||||
| false
|
||||
| {
|
||||
initialArgs: null | string[];
|
||||
defaultArgs: string[];
|
||||
}
|
||||
>(false);
|
||||
|
||||
const onChangeLaunchOptions = async () => {
|
||||
const initialArgs = await commands.projectGetCustomUnityArgs(projectPath);
|
||||
const defaultArgs = await commands.environmentGetDefaultUnityArguments();
|
||||
setOpenLaunchOptions({
|
||||
initialArgs,
|
||||
defaultArgs,
|
||||
});
|
||||
};
|
||||
const closeChangeLaunchOptions = () => {
|
||||
setOpenLaunchOptions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<HNavBar className={className}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
onClick={() => history.back()}
|
||||
>
|
||||
<ArrowLeft className={"w-5 h-5"} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tc("projects:manage:tooltip:back to projects")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 whitespace-pre">
|
||||
{projectName}
|
||||
</p>
|
||||
|
||||
<div className="relative flex gap-2 w-max flex-grow" />
|
||||
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
openUnity.openUnity(projectPath, unityVersion, unityRevision)
|
||||
}
|
||||
className={"rounded-r-none pl-4 pr-3"}
|
||||
>
|
||||
{tc("projects:button:open unity")}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContentBody
|
||||
projectPath={projectPath}
|
||||
onRemove={onRemove}
|
||||
onBackup={onBackup}
|
||||
onChangeLaunchOptions={onChangeLaunchOptions}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openUnity.dialog}
|
||||
{openLaunchOptions !== false && (
|
||||
<DialogOpen>
|
||||
<LaunchSettings
|
||||
projectPath={projectPath}
|
||||
initialValue={openLaunchOptions.initialArgs}
|
||||
defaultUnityArgs={openLaunchOptions.defaultArgs}
|
||||
close={closeChangeLaunchOptions}
|
||||
/>
|
||||
</DialogOpen>
|
||||
)}
|
||||
</HNavBar>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchSettings({
|
||||
projectPath,
|
||||
defaultUnityArgs,
|
||||
initialValue,
|
||||
close,
|
||||
}: {
|
||||
projectPath: string;
|
||||
defaultUnityArgs: string[];
|
||||
initialValue: string[] | null;
|
||||
close: () => void;
|
||||
}) {
|
||||
const context = useUnityArgumentsSettings(initialValue, defaultUnityArgs);
|
||||
|
||||
const saveAndClose = async () => {
|
||||
await commands.projectSetCustomUnityArgs(projectPath, context.currentValue);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
|
||||
<h3 className={"text-lg"}>
|
||||
{tc("projects:dialog:command-line arguments")}
|
||||
</h3>
|
||||
<UnityArgumentsSettings context={context} />
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={close} variant={"destructive"}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={saveAndClose} disabled={context.hasError}>
|
||||
{tc("general:button:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContentBody({
|
||||
projectPath,
|
||||
onRemove,
|
||||
onBackup,
|
||||
onChangeLaunchOptions,
|
||||
}: {
|
||||
projectPath: string;
|
||||
onRemove?: () => void;
|
||||
onBackup?: () => void;
|
||||
onChangeLaunchOptions?: () => void;
|
||||
}) {
|
||||
const openProjectFolder = () =>
|
||||
commands.utilOpen(projectPath, "ErrorIfNotExists");
|
||||
const forgetUnity = async () => {
|
||||
try {
|
||||
await commands.projectSetUnityPath(projectPath, null);
|
||||
toastSuccess(tc("projects:toast:forgot unity path"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
const unityPathQuery = useQuery({
|
||||
queryFn: () => commands.projectGetUnityPath(projectPath),
|
||||
queryKey: ["projectGetUnityPath", projectPath],
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const unityPath = unityPathQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onChangeLaunchOptions}>
|
||||
{tc("projects:menuitem:change launch options")}
|
||||
</DropdownMenuItem>
|
||||
{unityPath && (
|
||||
<DropdownMenuItem onClick={forgetUnity}>
|
||||
{tc("projects:menuitem:forget unity path")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={openProjectFolder}>
|
||||
{tc("projects:menuitem:open directory")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onBackup}>
|
||||
{tc("projects:menuitem:backup")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onRemove}
|
||||
className={"bg-destructive text-destructive-foreground"}
|
||||
>
|
||||
{tc("projects:remove project")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,730 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriUnityVersions } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { callAsyncCommand } from "@/lib/call-async-command";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useUnitySelectorDialog } from "@/lib/use-unity-selector-dialog";
|
||||
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { Fragment, useCallback } from "react";
|
||||
|
||||
type UnityInstallation = [path: string, version: string, fromHub: boolean];
|
||||
|
||||
function findRecommendedUnity(
|
||||
unityVersions: TauriUnityVersions,
|
||||
): FindUnityResult {
|
||||
const versions = unityVersions.unity_paths.filter(
|
||||
([_p, v, _]) => v === unityVersions.recommended_version,
|
||||
);
|
||||
|
||||
if (versions.length === 0) {
|
||||
return {
|
||||
expectingVersion: unityVersions.recommended_version,
|
||||
installLink: unityVersions.install_recommended_version_link,
|
||||
found: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
expectingVersion: unityVersions.recommended_version,
|
||||
found: true,
|
||||
installations: versions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useUnity2022Migration({
|
||||
projectPath,
|
||||
refresh,
|
||||
}: {
|
||||
projectPath: string;
|
||||
refresh?: () => void;
|
||||
}): Result<Record<string, never>> {
|
||||
return useMigrationInternal({
|
||||
projectPath,
|
||||
updateProjectPreUnityLaunch: async (project) =>
|
||||
await commands.projectMigrateProjectTo2022(project),
|
||||
findUnity: findRecommendedUnity,
|
||||
refresh,
|
||||
ConfirmComponent: MigrationConfirmMigrationDialog,
|
||||
dialogHeader: () => tc("projects:manage:dialog:unity migrate header"),
|
||||
});
|
||||
}
|
||||
|
||||
function MigrationConfirmMigrationDialog({ cancel, doMigrate }: ConfirmProps) {
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:dialog:vpm migrate description")}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => doMigrate(false)}
|
||||
variant={"destructive"}
|
||||
className="mr-1"
|
||||
>
|
||||
{tc("projects:button:migrate copy")}
|
||||
</Button>
|
||||
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnity2022PatchMigration({
|
||||
projectPath,
|
||||
refresh,
|
||||
}: {
|
||||
projectPath: string;
|
||||
refresh?: () => void;
|
||||
}): Result<Record<string, never>> {
|
||||
return useMigrationInternal({
|
||||
projectPath,
|
||||
updateProjectPreUnityLaunch: async () => {}, // nothing pre-launch
|
||||
findUnity: findRecommendedUnity,
|
||||
refresh,
|
||||
|
||||
ConfirmComponent: MigrationConfirmMigrationPatchDialog,
|
||||
dialogHeader: () => tc("projects:manage:dialog:unity migrate header"),
|
||||
});
|
||||
}
|
||||
|
||||
function MigrationConfirmMigrationPatchDialog({
|
||||
result,
|
||||
cancel,
|
||||
doMigrate,
|
||||
}: ConfirmProps) {
|
||||
const unity = result.expectingVersion;
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:dialog:migrate unity2022 patch description", { unity })}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// endregion unity version change
|
||||
|
||||
export function useUnityVersionChange({
|
||||
projectPath,
|
||||
refresh,
|
||||
}: {
|
||||
projectPath: string;
|
||||
refresh?: () => void;
|
||||
}): Result<{
|
||||
version: string;
|
||||
currentUnityVersion: string;
|
||||
isVRCProject: boolean;
|
||||
}> {
|
||||
const use = useMigrationInternal({
|
||||
projectPath,
|
||||
updateProjectPreUnityLaunch: async (project, data) => {
|
||||
if (
|
||||
data.isVRC &&
|
||||
data.kind === "upgradeMajor" &&
|
||||
data.targetUnityVersion.startsWith("2022.")
|
||||
) {
|
||||
await commands.projectMigrateProjectTo2022(project);
|
||||
}
|
||||
},
|
||||
findUnity: findUnityForUnityChange,
|
||||
refresh,
|
||||
ConfirmComponent: UnityVersionChange,
|
||||
dialogHeader: (data) => {
|
||||
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
|
||||
switch (data.kind) {
|
||||
case "upgradePatchOrMinor":
|
||||
case "upgradeMajor":
|
||||
return tc("projects:manage:dialog:unity migrate header");
|
||||
}
|
||||
}
|
||||
|
||||
return tc("projects:manage:dialog:unity change version header");
|
||||
},
|
||||
});
|
||||
|
||||
const request = use.request;
|
||||
|
||||
return {
|
||||
dialog: use.dialog,
|
||||
request: useCallback(
|
||||
({ version, currentUnityVersion, isVRCProject }) => {
|
||||
if (currentUnityVersion == null) throw new Error("unexpected");
|
||||
const v = detectChangeUnityKind(
|
||||
currentUnityVersion,
|
||||
version,
|
||||
isVRCProject,
|
||||
);
|
||||
request(v);
|
||||
},
|
||||
[request],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function UnityVersionChange({
|
||||
cancel,
|
||||
doMigrate,
|
||||
data,
|
||||
result,
|
||||
}: ConfirmProps<ChangeUnityData>) {
|
||||
// TODO: description
|
||||
|
||||
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
|
||||
// for supported migrations, show dialog same as migration
|
||||
switch (data.kind) {
|
||||
case "upgradePatchOrMinor":
|
||||
return (
|
||||
<MigrationConfirmMigrationPatchDialog
|
||||
cancel={cancel}
|
||||
doMigrate={doMigrate}
|
||||
result={result}
|
||||
data={{}}
|
||||
/>
|
||||
);
|
||||
case "upgradeMajor":
|
||||
return (
|
||||
<MigrationConfirmMigrationDialog
|
||||
cancel={cancel}
|
||||
doMigrate={doMigrate}
|
||||
result={result}
|
||||
data={{}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mainMessage: React.ReactNode;
|
||||
|
||||
switch (data.kind) {
|
||||
case "downgradeMajor":
|
||||
if (data.isVRC) {
|
||||
if (data.isTargetVersionSupportedByVRC) {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:downgrade major vrchat supported",
|
||||
"projects:manage:dialog:downgrade major",
|
||||
]);
|
||||
} else {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:downgrade major vrchat unsupported",
|
||||
"projects:manage:dialog:downgrade major",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
mainMessage = tc("projects:manage:dialog:downgrade major");
|
||||
}
|
||||
break;
|
||||
case "downgradePatchOrMinor":
|
||||
if (data.isVRC) {
|
||||
if (data.isTargetVersionSupportedByVRC) {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:downgrade minor vrchat supported",
|
||||
"projects:manage:dialog:downgrade minor",
|
||||
]);
|
||||
} else {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:downgrade minor vrchat unsupported",
|
||||
"projects:manage:dialog:downgrade minor",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
mainMessage = tc("projects:manage:dialog:downgrade minor");
|
||||
}
|
||||
break;
|
||||
case "upgradePatchOrMinor":
|
||||
if (data.isVRC) {
|
||||
if (data.isTargetVersionSupportedByVRC) {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:upgrade minor vrchat supported",
|
||||
"projects:manage:dialog:upgrade minor",
|
||||
]);
|
||||
} else {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:upgrade minor vrchat unsupported",
|
||||
"projects:manage:dialog:upgrade minor",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
mainMessage = tc("projects:manage:dialog:upgrade minor");
|
||||
}
|
||||
break;
|
||||
case "upgradeMajor":
|
||||
if (data.isVRC) {
|
||||
if (data.isTargetVersionSupportedByVRC) {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:upgrade major vrchat supported",
|
||||
"projects:manage:dialog:upgrade major",
|
||||
]);
|
||||
} else {
|
||||
mainMessage = tc([
|
||||
"projects:manage:dialog:upgrade major vrchat unsupported",
|
||||
"projects:manage:dialog:upgrade major",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
mainMessage = tc("projects:manage:dialog:upgrade major");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
assertNever(data.kind);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p className={"text-destructive"}>{mainMessage}</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
|
||||
{tc("projects:button:change unity version")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ChangeUnityKind =
|
||||
| "downgradeMajor"
|
||||
| "downgradePatchOrMinor"
|
||||
| "upgradePatchOrMinor"
|
||||
| "upgradeMajor";
|
||||
|
||||
type ChangeUnityData = (
|
||||
| {
|
||||
kind: ChangeUnityKind;
|
||||
isVRC: false;
|
||||
}
|
||||
| {
|
||||
kind: ChangeUnityKind;
|
||||
isVRC: true;
|
||||
isTargetVersionSupportedByVRC: boolean;
|
||||
}
|
||||
) & {
|
||||
targetUnityVersion: string;
|
||||
};
|
||||
|
||||
function detectChangeUnityKind(
|
||||
currentVersion: string,
|
||||
targetUnityVersion: string,
|
||||
isVRCProject: boolean,
|
||||
): ChangeUnityData {
|
||||
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
|
||||
const parsedCurrent = parseUnityVersion(currentVersion)!;
|
||||
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
|
||||
const parsedTarget = parseUnityVersion(targetUnityVersion)!;
|
||||
|
||||
const kind: ChangeUnityData["kind"] =
|
||||
compareUnityVersionString(currentVersion, targetUnityVersion) >= 0
|
||||
? parsedCurrent.major === parsedTarget.major
|
||||
? "downgradePatchOrMinor"
|
||||
: "downgradeMajor"
|
||||
: parsedCurrent.major === parsedTarget.major
|
||||
? "upgradePatchOrMinor"
|
||||
: "upgradeMajor";
|
||||
|
||||
if (isVRCProject) {
|
||||
const supportedVersions = ["2019.4.31f1", "2022.3.6f1", "2022.3.22f1"];
|
||||
return {
|
||||
kind,
|
||||
isVRC: true,
|
||||
isTargetVersionSupportedByVRC:
|
||||
supportedVersions.includes(targetUnityVersion),
|
||||
targetUnityVersion,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
kind,
|
||||
isVRC: false,
|
||||
targetUnityVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findUnityForUnityChange(
|
||||
unityVersions: TauriUnityVersions,
|
||||
data: ChangeUnityData,
|
||||
): FindUnityResult {
|
||||
const foundVersions = unityVersions.unity_paths.filter(
|
||||
([_p, v, _]) => v === data.targetUnityVersion,
|
||||
);
|
||||
if (foundVersions.length === 0) throw new Error("unreachable");
|
||||
return {
|
||||
expectingVersion: data.targetUnityVersion,
|
||||
found: true,
|
||||
installations: foundVersions,
|
||||
};
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
type StateInternal<Data> =
|
||||
| {
|
||||
state: "normal";
|
||||
}
|
||||
| {
|
||||
state: "confirm";
|
||||
data: Data;
|
||||
findResult: FindUnityResult & { found: true };
|
||||
}
|
||||
| {
|
||||
state: "noExactUnity2022";
|
||||
data: Data;
|
||||
findResult: FindUnityResult & { found: false };
|
||||
}
|
||||
| {
|
||||
state: "copyingProject";
|
||||
data: Data;
|
||||
}
|
||||
| {
|
||||
state: "updating";
|
||||
data: Data;
|
||||
}
|
||||
| {
|
||||
state: "finalizing";
|
||||
data: Data;
|
||||
lines: [number, string][];
|
||||
};
|
||||
|
||||
type Result<Data> = {
|
||||
dialog: React.ReactNode;
|
||||
request: (data: Data) => void;
|
||||
};
|
||||
|
||||
type ConfirmProps<Data = Record<string, never>> = {
|
||||
result: FindUnityResult;
|
||||
data: Data;
|
||||
cancel: () => void;
|
||||
doMigrate: (inPlace: boolean) => void;
|
||||
};
|
||||
|
||||
type FindUnityResult = FindUnityFoundResult | FindUnityNotFoundResult;
|
||||
|
||||
interface FindUnityFoundResult {
|
||||
expectingVersion: string;
|
||||
found: true;
|
||||
installations: UnityInstallation[];
|
||||
}
|
||||
|
||||
interface FindUnityNotFoundResult {
|
||||
expectingVersion: string;
|
||||
installLink: string;
|
||||
found: false;
|
||||
}
|
||||
|
||||
function useMigrationInternal<Data>({
|
||||
projectPath,
|
||||
updateProjectPreUnityLaunch,
|
||||
findUnity,
|
||||
refresh,
|
||||
|
||||
ConfirmComponent,
|
||||
dialogHeader,
|
||||
}: {
|
||||
projectPath: string;
|
||||
updateProjectPreUnityLaunch: (
|
||||
projectPath: string,
|
||||
data: Data,
|
||||
) => Promise<unknown>;
|
||||
findUnity: (unityVersions: TauriUnityVersions, data: Data) => FindUnityResult;
|
||||
refresh?: () => void;
|
||||
|
||||
ConfirmComponent: React.ComponentType<ConfirmProps<Data>>;
|
||||
dialogHeader: (data: Data) => React.ReactNode;
|
||||
}): Result<Data> {
|
||||
const router = useRouter();
|
||||
const unitySelector = useUnitySelectorDialog();
|
||||
|
||||
const [installStatus, setInstallStatus] = React.useState<StateInternal<Data>>(
|
||||
{ state: "normal" },
|
||||
);
|
||||
|
||||
const request = async (data: Data) => {
|
||||
if (await commands.projectIsUnityLaunching(projectPath)) {
|
||||
toastError(tt("projects:toast:close unity before migration"));
|
||||
return;
|
||||
}
|
||||
const unityVersions = await commands.environmentUnityVersions();
|
||||
const findResult = findUnity(unityVersions, data);
|
||||
if (!findResult.found) {
|
||||
setInstallStatus({ state: "noExactUnity2022", data, findResult });
|
||||
} else setInstallStatus({ state: "confirm", data, findResult });
|
||||
};
|
||||
|
||||
const startChangeUnityVersion = async (
|
||||
inPlace: boolean,
|
||||
unityFound: UnityInstallation[],
|
||||
data: Data,
|
||||
) => {
|
||||
try {
|
||||
switch (unityFound.length) {
|
||||
case 0:
|
||||
throw new Error("unreachable");
|
||||
case 1:
|
||||
void continueChangeUnityVersion(inPlace, unityFound[0][0], data);
|
||||
break;
|
||||
default: {
|
||||
const selected = await unitySelector.select(unityFound);
|
||||
if (selected == null) setInstallStatus({ state: "normal" });
|
||||
else
|
||||
void continueChangeUnityVersion(inPlace, selected.unityPath, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
setInstallStatus({ state: "normal" });
|
||||
}
|
||||
};
|
||||
|
||||
const continueChangeUnityVersion = async (
|
||||
inPlace: boolean,
|
||||
unityPath: string,
|
||||
data: Data,
|
||||
) => {
|
||||
try {
|
||||
let migrateProjectPath: string;
|
||||
if (inPlace) {
|
||||
migrateProjectPath = projectPath;
|
||||
} else {
|
||||
// copy
|
||||
setInstallStatus({ state: "copyingProject", data });
|
||||
migrateProjectPath =
|
||||
await commands.environmentCopyProjectForMigration(projectPath);
|
||||
}
|
||||
setInstallStatus({ state: "updating", data });
|
||||
await updateProjectPreUnityLaunch(migrateProjectPath, data);
|
||||
setInstallStatus({ state: "finalizing", lines: [], data });
|
||||
let lineNumber = 0;
|
||||
const [, promise] = callAsyncCommand(
|
||||
commands.projectCallUnityForMigration,
|
||||
[migrateProjectPath, unityPath],
|
||||
(lineString) => {
|
||||
setInstallStatus((prev) => {
|
||||
if (prev.state !== "finalizing") return prev;
|
||||
lineNumber++;
|
||||
const line: [number, string] = [lineNumber, lineString];
|
||||
if (prev.lines.length > 200) {
|
||||
return { ...prev, lines: [...prev.lines.slice(1), line] };
|
||||
} else {
|
||||
return { ...prev, lines: [...prev.lines, line] };
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
const finalizeResult = await promise;
|
||||
if (finalizeResult === "cancelled") {
|
||||
throw new Error("unexpectedly cancelled");
|
||||
}
|
||||
switch (finalizeResult.type) {
|
||||
case "ExistsWithNonZero":
|
||||
toastError(tt("projects:toast:unity exits with non-zero"));
|
||||
break;
|
||||
case "FinishedSuccessfully":
|
||||
toastSuccess(tt("projects:toast:unity migrated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(finalizeResult);
|
||||
}
|
||||
if (inPlace) {
|
||||
setInstallStatus({ state: "normal" });
|
||||
refresh?.();
|
||||
} else {
|
||||
setInstallStatus({ state: "normal" });
|
||||
router.replace(
|
||||
`/projects/manage?${new URLSearchParams({ projectPath: migrateProjectPath })}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
setInstallStatus({ state: "normal" });
|
||||
}
|
||||
};
|
||||
|
||||
const cancelChangeUnityVersion = async () => {
|
||||
setInstallStatus({ state: "normal" });
|
||||
};
|
||||
|
||||
let dialogHeaderForState: React.ReactNode = null;
|
||||
let dialogBodyForState: React.ReactNode = null;
|
||||
|
||||
switch (installStatus.state) {
|
||||
case "normal":
|
||||
dialogBodyForState = null;
|
||||
break;
|
||||
case "confirm":
|
||||
dialogHeaderForState = dialogHeader(installStatus.data);
|
||||
dialogBodyForState = (
|
||||
<ConfirmComponent
|
||||
result={installStatus.findResult}
|
||||
cancel={cancelChangeUnityVersion}
|
||||
data={installStatus.data}
|
||||
doMigrate={(inPlace) =>
|
||||
startChangeUnityVersion(
|
||||
inPlace,
|
||||
installStatus.findResult.installations,
|
||||
installStatus.data,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "copyingProject":
|
||||
dialogHeaderForState = dialogHeader(installStatus.data);
|
||||
dialogBodyForState = <MigrationCopyingDialog />;
|
||||
break;
|
||||
case "updating":
|
||||
dialogHeaderForState = dialogHeader(installStatus.data);
|
||||
dialogBodyForState = <MigrationMigratingDialog />;
|
||||
break;
|
||||
case "noExactUnity2022":
|
||||
dialogHeaderForState = dialogHeader(installStatus.data);
|
||||
dialogBodyForState = (
|
||||
<NoExactUnity2022Dialog
|
||||
expectedVersion={installStatus.findResult.expectingVersion}
|
||||
installWithUnityHubLink={installStatus.findResult.installLink}
|
||||
close={cancelChangeUnityVersion}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "finalizing":
|
||||
dialogHeaderForState = dialogHeader(installStatus.data);
|
||||
dialogBodyForState = (
|
||||
<MigrationCallingUnityForMigrationDialog lines={installStatus.lines} />
|
||||
);
|
||||
break;
|
||||
default:
|
||||
assertNever(installStatus);
|
||||
}
|
||||
|
||||
return {
|
||||
dialog: (
|
||||
<>
|
||||
{unitySelector.dialog}
|
||||
{dialogBodyForState == null ? null : (
|
||||
<DialogOpen className={"whitespace-normal leading-relaxed"}>
|
||||
<DialogTitle>{dialogHeaderForState}</DialogTitle>
|
||||
{dialogBodyForState}
|
||||
</DialogOpen>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
function MigrationCopyingDialog() {
|
||||
return (
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:pre-migrate copying...")}</p>
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
</DialogDescription>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationMigratingDialog() {
|
||||
return (
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:migrating...")}</p>
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
</DialogDescription>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationCallingUnityForMigrationDialog({
|
||||
lines,
|
||||
}: {
|
||||
lines: [number, string][];
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to scroll to bottom on lines changed
|
||||
React.useEffect(() => {
|
||||
ref.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:manage:dialog:unity migrate finalizing...")}</p>
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<pre
|
||||
className={
|
||||
"overflow-y-auto h-[50vh] bg-secondary text-secondary-foreground text-sm"
|
||||
}
|
||||
>
|
||||
{lines.map(([lineNumber, line]) => (
|
||||
<Fragment key={lineNumber}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</Fragment>
|
||||
))}
|
||||
<div ref={ref} />
|
||||
</pre>
|
||||
</DialogDescription>
|
||||
);
|
||||
}
|
||||
|
||||
function NoExactUnity2022Dialog({
|
||||
expectedVersion,
|
||||
installWithUnityHubLink,
|
||||
close,
|
||||
}: {
|
||||
expectedVersion: string;
|
||||
installWithUnityHubLink: string;
|
||||
close: () => void;
|
||||
}) {
|
||||
const openUnityHub = async () => {
|
||||
await commands.utilOpenUrl(installWithUnityHubLink);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<p>
|
||||
{tc(
|
||||
"projects:manage:dialog:exact version unity not found for patch migration description",
|
||||
{ unity: expectedVersion },
|
||||
)}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={openUnityHub}>
|
||||
{tc("projects:dialog:open unity hub")}
|
||||
</Button>
|
||||
<Button onClick={close} className="mr-1">
|
||||
{tc("general:button:close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriBasePackageInfo,
|
||||
TauriPackage,
|
||||
TauriPackageChange,
|
||||
TauriPendingProjectChanges,
|
||||
TauriRemoveReason,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastInfo, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { compareVersion, toVersionString } from "@/lib/version";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { PackageRowInfo } from "./collect-package-row-info";
|
||||
|
||||
export type RequestedOperation =
|
||||
| {
|
||||
type: "install";
|
||||
pkg: TauriPackage;
|
||||
hasUnityIncompatibleLatest?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "upgradeAll";
|
||||
hasUnityIncompatibleLatest: boolean;
|
||||
}
|
||||
| {
|
||||
type: "resolve";
|
||||
}
|
||||
| {
|
||||
type: "reinstallAll";
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
displayName: string;
|
||||
}
|
||||
| {
|
||||
type: "bulkInstalled";
|
||||
hasUnityIncompatibleLatest: boolean;
|
||||
}
|
||||
| {
|
||||
type: "bulkReinstalled";
|
||||
}
|
||||
| {
|
||||
type: "bulkRemoved";
|
||||
};
|
||||
|
||||
type InstallStatus =
|
||||
| {
|
||||
status: "normal";
|
||||
}
|
||||
| {
|
||||
status: "creatingChanges";
|
||||
}
|
||||
| {
|
||||
status: "promptingChanges";
|
||||
changes: TauriPendingProjectChanges;
|
||||
requested: RequestedOperation;
|
||||
}
|
||||
| {
|
||||
status: "applyingChanges";
|
||||
};
|
||||
|
||||
interface PackageChangeDialog {
|
||||
createChanges: (
|
||||
operation: RequestedOperation,
|
||||
createPromise: Promise<TauriPendingProjectChanges>,
|
||||
) => void;
|
||||
dialog: React.ReactNode;
|
||||
installingPackage: boolean;
|
||||
}
|
||||
|
||||
export function usePackageChangeDialog({
|
||||
projectPath,
|
||||
onRefreshProject,
|
||||
packageRowsData,
|
||||
existingPackages,
|
||||
}: {
|
||||
projectPath: string;
|
||||
onRefreshProject: () => void;
|
||||
packageRowsData: PackageRowInfo[];
|
||||
existingPackages?: [string, TauriBasePackageInfo][];
|
||||
}): PackageChangeDialog {
|
||||
const [installStatus, setInstallStatus] = useState<InstallStatus>({
|
||||
status: "normal",
|
||||
});
|
||||
|
||||
const createChanges = useCallback(
|
||||
async (
|
||||
operation: RequestedOperation,
|
||||
createPromise: Promise<TauriPendingProjectChanges>,
|
||||
) => {
|
||||
try {
|
||||
setInstallStatus({ status: "creatingChanges" });
|
||||
const changes = await createPromise;
|
||||
setInstallStatus({
|
||||
status: "promptingChanges",
|
||||
changes,
|
||||
requested: operation,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
setInstallStatus({ status: "normal" });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
let dialogForState: React.ReactNode = null;
|
||||
|
||||
switch (installStatus.status) {
|
||||
case "promptingChanges": {
|
||||
const applyChanges = async ({
|
||||
changes,
|
||||
requested,
|
||||
}: {
|
||||
changes: TauriPendingProjectChanges;
|
||||
requested: RequestedOperation;
|
||||
}) => {
|
||||
try {
|
||||
setInstallStatus({ status: "applyingChanges" });
|
||||
await commands.projectApplyPendingChanges(
|
||||
projectPath,
|
||||
changes.changes_version,
|
||||
);
|
||||
setInstallStatus({ status: "normal" });
|
||||
onRefreshProject();
|
||||
|
||||
switch (requested.type) {
|
||||
case "install":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:package installed", {
|
||||
name: requested.pkg.display_name ?? requested.pkg.name,
|
||||
version: toVersionString(requested.pkg.version),
|
||||
}),
|
||||
);
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:the package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "remove":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:package removed", {
|
||||
name: requested.displayName,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "resolve":
|
||||
toastSuccess(tt("projects:manage:toast:resolved"));
|
||||
break;
|
||||
case "reinstallAll":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:all packages reinstalled"),
|
||||
);
|
||||
break;
|
||||
case "upgradeAll":
|
||||
toastSuccess(tt("projects:manage:toast:all packages upgraded"));
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:some package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "bulkInstalled":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:selected packages installed"),
|
||||
);
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:some package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "bulkRemoved":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:selected packages removed"),
|
||||
);
|
||||
break;
|
||||
case "bulkReinstalled":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:selected packages reinstalled"),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
assertNever(requested);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setInstallStatus({ status: "normal" });
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
setInstallStatus({ status: "normal" });
|
||||
try {
|
||||
await commands.projectClearPendingChanges();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
dialogForState = (
|
||||
<ProjectChangesDialog
|
||||
packages={packageRowsData}
|
||||
changes={installStatus.changes}
|
||||
existingPackages={existingPackages}
|
||||
cancel={cancel}
|
||||
apply={() => applyChanges(installStatus)}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dialog: dialogForState,
|
||||
createChanges,
|
||||
installingPackage: installStatus.status !== "normal",
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectChangesDialog({
|
||||
changes,
|
||||
packages,
|
||||
existingPackages,
|
||||
cancel,
|
||||
apply,
|
||||
}: {
|
||||
changes: TauriPendingProjectChanges;
|
||||
packages: PackageRowInfo[];
|
||||
existingPackages?: [string, TauriBasePackageInfo][];
|
||||
cancel: () => void;
|
||||
apply: () => void;
|
||||
}) {
|
||||
const versionConflicts = changes.conflicts.filter(
|
||||
([_, c]) => c.packages.length > 0,
|
||||
);
|
||||
const unityConflicts = changes.conflicts.filter(([_, c]) => c.unity_conflict);
|
||||
|
||||
const getPackageDisplayName = useMemo(() => {
|
||||
const packagesById = new Map(packages.map((p) => [p.id, p]));
|
||||
return (pkgId: string) => packagesById.get(pkgId)?.displayName ?? pkgId;
|
||||
}, [packages]);
|
||||
|
||||
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className={"p-3"}>
|
||||
<p className={"font-normal"}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
function isInstallNew(
|
||||
pair: [string, TauriPackageChange],
|
||||
): pair is [string, { InstallNew: TauriPackage }] {
|
||||
return "InstallNew" in pair[1];
|
||||
}
|
||||
|
||||
function isRemove(
|
||||
pair: [string, TauriPackageChange],
|
||||
): pair is [string, { Remove: TauriRemoveReason }] {
|
||||
return "Remove" in pair[1];
|
||||
}
|
||||
|
||||
const existingPackageMap = new Map(existingPackages ?? []);
|
||||
|
||||
const installingPackages = changes.package_changes.filter(isInstallNew);
|
||||
const removingPackages = changes.package_changes.filter(isRemove);
|
||||
|
||||
const reInstallingPackages = installingPackages.filter(([pkgId, c]) => {
|
||||
const info = existingPackageMap.get(pkgId);
|
||||
return (
|
||||
info !== undefined &&
|
||||
compareVersion(c.InstallNew.version, info.version) === 0
|
||||
);
|
||||
});
|
||||
const installingNewPackages = installingPackages.filter(([pkgId, c]) => {
|
||||
const info = existingPackageMap.get(pkgId);
|
||||
return (
|
||||
info === undefined ||
|
||||
compareVersion(c.InstallNew.version, info.version) !== 0
|
||||
);
|
||||
});
|
||||
|
||||
const removingRequestedPackages = removingPackages.filter(
|
||||
([_, c]) => c.Remove === "Requested",
|
||||
);
|
||||
const removingLegacyPackages = removingPackages.filter(
|
||||
([_, c]) => c.Remove === "Legacy",
|
||||
);
|
||||
const removingUnusedPackages = removingPackages.filter(
|
||||
([_, c]) => c.Remove === "Unused",
|
||||
);
|
||||
|
||||
reInstallingPackages.sort(comparePackageChangeByName);
|
||||
installingNewPackages.sort(comparePackageChangeByName);
|
||||
removingRequestedPackages.sort(comparePackageChangeByName);
|
||||
removingLegacyPackages.sort(comparePackageChangeByName);
|
||||
removingUnusedPackages.sort(comparePackageChangeByName);
|
||||
|
||||
const ChangelogButton = ({ url }: { url?: string | null }) => {
|
||||
if (url == null) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return (
|
||||
<Button
|
||||
className={"ml-1 px-2"}
|
||||
size={"sm"}
|
||||
onClick={() => commands.utilOpenUrl(url)}
|
||||
>
|
||||
{tc("projects:manage:button:see changelog")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogOpen className={"whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:manage:button:apply changes")}</DialogTitle>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"overflow-y-auto max-h-[50vh]"}>
|
||||
<p>{tc("projects:manage:dialog:confirm changes description")}</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{installingNewPackages.map(([pkgId, pkgChange]) => {
|
||||
const name =
|
||||
pkgChange.InstallNew.display_name ?? pkgChange.InstallNew.name;
|
||||
const version = toVersionString(pkgChange.InstallNew.version);
|
||||
|
||||
return (
|
||||
<div key={pkgId} className={"flex items-center p-3"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:install package", {
|
||||
name,
|
||||
version,
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={pkgChange.InstallNew.changelog_url} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{installingNewPackages.length > 0 &&
|
||||
reInstallingPackages.length > 0 && <hr />}
|
||||
{reInstallingPackages.map(([pkgId, pkgChange]) => {
|
||||
const name =
|
||||
pkgChange.InstallNew.display_name ?? pkgChange.InstallNew.name;
|
||||
const version = toVersionString(pkgChange.InstallNew.version);
|
||||
|
||||
return (
|
||||
<div key={pkgId} className={"flex items-center p-3"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:reinstall package", {
|
||||
name,
|
||||
version,
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={pkgChange.InstallNew.changelog_url} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{removingRequestedPackages.map(([pkgId, _]) => {
|
||||
const name = getPackageDisplayName(pkgId);
|
||||
return (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc("projects:manage:dialog:uninstall package as requested", {
|
||||
name,
|
||||
})}
|
||||
</TypographyItem>
|
||||
);
|
||||
})}
|
||||
{removingLegacyPackages.map(([pkgId, _]) => {
|
||||
const name = getPackageDisplayName(pkgId);
|
||||
return (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc("projects:manage:dialog:uninstall package as legacy", {
|
||||
name,
|
||||
})}
|
||||
</TypographyItem>
|
||||
);
|
||||
})}
|
||||
{removingUnusedPackages.map(([pkgId, _]) => {
|
||||
const name = getPackageDisplayName(pkgId);
|
||||
return (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc("projects:manage:dialog:uninstall package as unused", {
|
||||
name,
|
||||
})}
|
||||
</TypographyItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{versionConflicts.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:manage:dialog:package version conflicts", {
|
||||
count: versionConflicts.length,
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{versionConflicts.map(([pkgId, conflict]) => {
|
||||
return (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc("projects:manage:dialog:conflicts with", {
|
||||
pkg: getPackageDisplayName(pkgId),
|
||||
other: conflict.packages
|
||||
.map((p) => getPackageDisplayName(p))
|
||||
.join(", "),
|
||||
})}
|
||||
</TypographyItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{unityConflicts.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:manage:dialog:unity version conflicts", {
|
||||
count: unityConflicts.length,
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{unityConflicts.map(([pkgId, _]) => (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc(
|
||||
"projects:manage:dialog:package not supported your unity",
|
||||
{ pkg: getPackageDisplayName(pkgId) },
|
||||
)}
|
||||
</TypographyItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{changes.remove_legacy_files.length > 0 ||
|
||||
changes.remove_legacy_folders.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc(
|
||||
"projects:manage:dialog:files and directories are removed as legacy",
|
||||
)}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{changes.remove_legacy_files.map((f) => (
|
||||
<TypographyItem key={f}>{f}</TypographyItem>
|
||||
))}
|
||||
{changes.remove_legacy_folders.map((f) => (
|
||||
<TypographyItem key={f}>{f}</TypographyItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={apply} variant={"destructive"}>
|
||||
{tc("projects:manage:button:apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogOpen>
|
||||
);
|
||||
}
|
||||
|
||||
function comparePackageChangeByName(
|
||||
[aName, _1]: [string, TauriPackageChange],
|
||||
[bName, _2]: [string, TauriPackageChange],
|
||||
): number {
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { SearchBox } from "@/components/SearchBox";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { isFindKey, useDocumentEvent } from "@/lib/events";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import { type OpenUnityFunction, useOpenUnity } from "@/lib/use-open-unity";
|
||||
import { compareUnityVersionString } from "@/lib/version";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronsUpDown,
|
||||
RefreshCw,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CreateProject } from "./create-project";
|
||||
import { ProjectRow } from "./project-row";
|
||||
|
||||
const sortings = ["lastModified", "name", "unity", "type"] as const;
|
||||
|
||||
type SimpleSorting = (typeof sortings)[number];
|
||||
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
|
||||
|
||||
function isSorting(s: string): s is Sorting {
|
||||
return sortings.some(
|
||||
(sorting) => sorting === s || `${sorting}Reversed` === s,
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const result = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: commands.environmentProjects,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingOther, setLoadingOther] = useState(false);
|
||||
const [createProjectState, setCreateProjectState] = useState<
|
||||
"normal" | "creating"
|
||||
>("normal");
|
||||
const openUnity = useOpenUnity();
|
||||
|
||||
const startCreateProject = () => setCreateProjectState("creating");
|
||||
|
||||
const loading = result.isFetching || loadingOther;
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<ProjectViewHeader
|
||||
className={"flex-shrink-0"}
|
||||
refresh={() => result.refetch()}
|
||||
startCreateProject={startCreateProject}
|
||||
isLoading={loading}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
|
||||
{result.status === "pending" ? (
|
||||
<Card className="w-full shadow-none overflow-hidden p-4">
|
||||
{tc("general:loading...")}
|
||||
</Card>
|
||||
) : result.status === "error" ? (
|
||||
<Card className="w-full shadow-none overflow-hidden p-4">
|
||||
{tc("projects:error:load error", { msg: result.error.message })}
|
||||
</Card>
|
||||
) : (
|
||||
<ProjectsTableCard
|
||||
projects={result.data}
|
||||
search={search}
|
||||
loading={loading}
|
||||
openUnity={openUnity.openUnity}
|
||||
refresh={() => result.refetch()}
|
||||
onRemoved={() => result.refetch()}
|
||||
/>
|
||||
)}
|
||||
{createProjectState === "creating" && (
|
||||
<CreateProject
|
||||
close={() => setCreateProjectState("normal")}
|
||||
refetch={() => result.refetch()}
|
||||
/>
|
||||
)}
|
||||
{openUnity.dialog}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function compareProjectType(
|
||||
a: TauriProjectType,
|
||||
b: TauriProjectType,
|
||||
): 0 | -1 | 1 {
|
||||
if (a === b) return 0;
|
||||
|
||||
// legacy unknown
|
||||
if (a === "LegacySdk2") return 1;
|
||||
if (b === "LegacySdk2") return -1;
|
||||
if (a === "UpmStarter") return 1;
|
||||
if (b === "UpmStarter") return -1;
|
||||
|
||||
// legacy worlds
|
||||
if (a === "LegacyWorlds") return 1;
|
||||
if (b === "LegacyWorlds") return -1;
|
||||
if (a === "UpmWorlds") return 1;
|
||||
if (b === "UpmWorlds") return -1;
|
||||
|
||||
// legacy avatars
|
||||
if (a === "LegacyAvatars") return 1;
|
||||
if (b === "LegacyAvatars") return -1;
|
||||
if (a === "UpmAvatars") return 1;
|
||||
if (b === "UpmAvatars") return -1;
|
||||
|
||||
// unknown
|
||||
if (a === "Unknown") return 1;
|
||||
if (b === "Unknown") return -1;
|
||||
if (a === "VpmStarter") return 1;
|
||||
if (b === "VpmStarter") return -1;
|
||||
|
||||
// worlds
|
||||
if (a === "Worlds") return 1;
|
||||
if (b === "Worlds") return -1;
|
||||
|
||||
// avatars
|
||||
if (a === "Avatars") return 1;
|
||||
if (b === "Avatars") return -1;
|
||||
|
||||
assertNever(a, "project type");
|
||||
}
|
||||
|
||||
function ProjectsTableCard({
|
||||
projects,
|
||||
search,
|
||||
onRemoved,
|
||||
loading,
|
||||
refresh,
|
||||
openUnity,
|
||||
}: {
|
||||
projects: TauriProject[];
|
||||
openUnity: OpenUnityFunction;
|
||||
search?: string;
|
||||
loading?: boolean;
|
||||
onRemoved?: () => void;
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const [sorting, setSortingState] = useState<Sorting>("lastModified");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let newSorting = await commands.environmentGetProjectSorting();
|
||||
if (newSorting === null) newSorting = "lastModified";
|
||||
if (!isSorting(newSorting)) {
|
||||
setSortingState("lastModified");
|
||||
} else {
|
||||
setSortingState(newSorting);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const projectsShown = useMemo(() => {
|
||||
const searched = projects.filter((project) =>
|
||||
project.name.toLowerCase().includes(search?.toLowerCase() ?? ""),
|
||||
);
|
||||
searched.sort((a, b) => b.last_modified - a.last_modified);
|
||||
switch (sorting) {
|
||||
case "lastModified":
|
||||
// already sorted
|
||||
break;
|
||||
case "lastModifiedReversed":
|
||||
searched.sort((a, b) => a.last_modified - b.last_modified);
|
||||
break;
|
||||
case "name":
|
||||
searched.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "nameReversed":
|
||||
searched.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "type":
|
||||
searched.sort((a, b) =>
|
||||
compareProjectType(a.project_type, b.project_type),
|
||||
);
|
||||
break;
|
||||
case "typeReversed":
|
||||
searched.sort((a, b) =>
|
||||
compareProjectType(b.project_type, a.project_type),
|
||||
);
|
||||
break;
|
||||
case "unity":
|
||||
searched.sort((a, b) => compareUnityVersionString(a.unity, b.unity));
|
||||
break;
|
||||
case "unityReversed":
|
||||
searched.sort((a, b) => compareUnityVersionString(b.unity, a.unity));
|
||||
break;
|
||||
default:
|
||||
assertNever(sorting);
|
||||
}
|
||||
searched.sort((a, b) => {
|
||||
if (a.favorite && !b.favorite) return -1;
|
||||
if (!a.favorite && b.favorite) return 1;
|
||||
return 0;
|
||||
});
|
||||
return searched;
|
||||
}, [projects, sorting, search]);
|
||||
|
||||
const thClass = "sticky top-0 z-10 border-b border-primary p-2.5";
|
||||
const iconClass = "size-3 invisible project-table-header-chevron-up-down";
|
||||
|
||||
const setSorting = async (simpleSorting: SimpleSorting) => {
|
||||
let newSorting: Sorting;
|
||||
if (sorting === simpleSorting) {
|
||||
newSorting = `${simpleSorting}Reversed`;
|
||||
} else if (sorting === `${simpleSorting}Reversed`) {
|
||||
newSorting = simpleSorting;
|
||||
} else {
|
||||
newSorting = simpleSorting;
|
||||
}
|
||||
setSortingState(newSorting);
|
||||
|
||||
try {
|
||||
await commands.environmentSetProjectSorting(newSorting);
|
||||
} catch (e) {
|
||||
console.error("Error setting project sorting", e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const headerBg = (target: SimpleSorting) =>
|
||||
sorting === target || sorting === `${target}Reversed`
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground";
|
||||
const icon = (target: SimpleSorting) =>
|
||||
sorting === target ? (
|
||||
<ChevronDown className={"size-3"} />
|
||||
) : sorting === `${target}Reversed` ? (
|
||||
<ChevronUp className={"size-3"} />
|
||||
) : (
|
||||
<ChevronsUpDown className={iconClass} />
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollableCardTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${thClass} bg-secondary text-secondary-foreground`}>
|
||||
<Star className={"size-4"} />
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("name")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("name")}
|
||||
>
|
||||
{icon("name")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("general:name")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("type")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("type")}
|
||||
>
|
||||
{icon("type")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("projects:type")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("unity")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("unity")}
|
||||
>
|
||||
{icon("unity")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("projects:unity")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("lastModified")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("lastModified")}
|
||||
>
|
||||
{icon("lastModified")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("projects:last modified")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} bg-secondary text-secondary-foreground`} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectsShown.map((project) => (
|
||||
<ProjectRow
|
||||
key={project.index}
|
||||
project={project}
|
||||
loading={loading}
|
||||
refresh={refresh}
|
||||
onRemoved={onRemoved}
|
||||
openUnity={openUnity}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectViewHeader({
|
||||
className,
|
||||
refresh,
|
||||
startCreateProject,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
}: {
|
||||
className?: string;
|
||||
refresh?: () => void;
|
||||
startCreateProject?: () => void;
|
||||
isLoading?: boolean;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
}) {
|
||||
const [addProjectWithPicker, dialog] = useFilePickerFunction(
|
||||
commands.environmentAddProjectWithPicker,
|
||||
);
|
||||
|
||||
const addProject = async () => {
|
||||
try {
|
||||
const result = await addProjectWithPicker();
|
||||
switch (result) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tt("projects:toast:project added"));
|
||||
refresh?.();
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastError(tt("projects:toast:project already exists"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error adding project", e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useDocumentEvent(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (isFindKey(e)) {
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HNavBar className={`${className}`}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
|
||||
{tc("projects")}
|
||||
</p>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
onClick={() => refresh?.()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className={"w-5 h-5"} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tc("projects:tooltip:refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<SearchBox
|
||||
className={"w-max flex-grow"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
ref={searchRef}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<Button
|
||||
className={"rounded-r-none pl-4 pr-3"}
|
||||
onClick={startCreateProject}
|
||||
>
|
||||
{tc("projects:create new project")}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={addProject}>
|
||||
{tc("projects:add existing project")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{dialog}
|
||||
</HNavBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBackupProjectModal } from "@/lib/backup-project";
|
||||
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { useRemoveProjectModal } from "@/lib/remove-project";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import type { OpenUnityFunction } from "@/lib/use-open-unity";
|
||||
import {
|
||||
CircleHelp,
|
||||
CircleUserRound,
|
||||
Ellipsis,
|
||||
Globe,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const ProjectDisplayType: Record<
|
||||
TauriProjectType,
|
||||
"avatars" | "worlds" | "sdk2" | "unknown"
|
||||
> = {
|
||||
Unknown: "unknown",
|
||||
LegacySdk2: "sdk2",
|
||||
LegacyWorlds: "worlds",
|
||||
LegacyAvatars: "avatars",
|
||||
UpmWorlds: "worlds",
|
||||
UpmAvatars: "avatars",
|
||||
UpmStarter: "unknown",
|
||||
Worlds: "worlds",
|
||||
Avatars: "avatars",
|
||||
VpmStarter: "unknown",
|
||||
};
|
||||
|
||||
const LegacyProjectTypes = [
|
||||
"LegacySdk2",
|
||||
"LegacyWorlds",
|
||||
"LegacyAvatars",
|
||||
"UpmWorlds",
|
||||
"UpmAvatars",
|
||||
"UpmStarter",
|
||||
];
|
||||
|
||||
export function ProjectRow({
|
||||
project,
|
||||
openUnity,
|
||||
onRemoved,
|
||||
loading,
|
||||
refresh,
|
||||
}: {
|
||||
project: TauriProject;
|
||||
openUnity: OpenUnityFunction;
|
||||
onRemoved?: () => void;
|
||||
loading?: boolean;
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const removeProjectModal = useRemoveProjectModal({ onRemoved });
|
||||
const backupProjectModal = useBackupProjectModal();
|
||||
|
||||
const cellClass = "p-2.5";
|
||||
const noGrowCellClass = `${cellClass} w-1`;
|
||||
const typeIconClass = "w-5 h-5";
|
||||
|
||||
const projectTypeKind = ProjectDisplayType[project.project_type] ?? "unknown";
|
||||
const displayType = tc(`projects:type:${projectTypeKind}`);
|
||||
const isLegacy = LegacyProjectTypes.includes(project.project_type);
|
||||
const lastModified = new Date(project.last_modified);
|
||||
const lastModifiedHumanReadable = `${lastModified.getFullYear().toString().padStart(4, "0")}-${(lastModified.getMonth() + 1).toString().padStart(2, "0")}-${lastModified.getDate().toString().padStart(2, "0")} ${lastModified.getHours().toString().padStart(2, "0")}:${lastModified.getMinutes().toString().padStart(2, "0")}:${lastModified.getSeconds().toString().padStart(2, "0")}`;
|
||||
|
||||
const openProjectFolder = () =>
|
||||
commands.utilOpen(project.path, "ErrorIfNotExists");
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
try {
|
||||
await commands.environmentSetFavoriteProject(
|
||||
project.list_version,
|
||||
project.index,
|
||||
!project.favorite,
|
||||
);
|
||||
refresh?.();
|
||||
} catch (e) {
|
||||
console.error("Error migrating project", e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const removed = !project.is_exists;
|
||||
|
||||
return (
|
||||
<ProjectRowContext.Provider value={{ removed, loading: Boolean(loading) }}>
|
||||
<tr
|
||||
className={`even:bg-secondary/30 ${removed || loading ? "opacity-50" : ""}`}
|
||||
>
|
||||
<td className={`${cellClass} w-3`}>
|
||||
<div className={"relative inline-flex"}>
|
||||
<Checkbox
|
||||
checked={project.favorite}
|
||||
onCheckedChange={onToggleFavorite}
|
||||
disabled={removed || loading}
|
||||
className="hover:before:content-none before:transition-none border-none !text-primary peer"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"text-background opacity-0 peer-data-[state=checked]:opacity-100 pointer-events-none absolute top-2/4 left-2/4 -translate-y-2/4 -translate-x-2/4"
|
||||
}
|
||||
>
|
||||
<Star strokeWidth={3} className={"size-3"} />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${cellClass} max-w-64 overflow-hidden`}>
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfRemoved
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfExists
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<p className="font-normal whitespace-pre">{project.name}</p>
|
||||
</TooltipTriggerIfExists>
|
||||
<TooltipContent>{project.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfExists
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<p className="font-normal opacity-50 text-sm whitespace-pre">
|
||||
{project.path}
|
||||
</p>
|
||||
</TooltipTriggerIfExists>
|
||||
<TooltipContent>{project.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipTriggerIfRemoved>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:no directory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className={`${cellClass} w-[8em] min-w-[8em]`}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex items-center">
|
||||
{projectTypeKind === "avatars" ? (
|
||||
<CircleUserRound className={typeIconClass} />
|
||||
) : projectTypeKind === "worlds" ? (
|
||||
<Globe className={typeIconClass} />
|
||||
) : (
|
||||
<CircleHelp className={typeIconClass} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="font-normal">{displayType}</p>
|
||||
{isLegacy && (
|
||||
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
|
||||
{tc("projects:type:legacy")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<p className="font-normal">{project.unity}</p>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<time dateTime={lastModified.toISOString()}>
|
||||
<time className="font-normal">
|
||||
{formatDateOffset(project.last_modified)}
|
||||
</time>
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{lastModifiedHumanReadable}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<div className="flex flex-row gap-2 max-w-min">
|
||||
<ButtonDisabledIfRemoved
|
||||
onClick={() =>
|
||||
openUnity(project.path, project.unity, project.unity_revision)
|
||||
}
|
||||
>
|
||||
{tc("projects:button:open unity")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
<ManageOrMigrateButton project={project} refresh={refresh} />
|
||||
<ButtonDisabledIfRemoved
|
||||
onClick={() => backupProjectModal.startBackup(project)}
|
||||
variant={"success"}
|
||||
>
|
||||
{tc("projects:backup")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={"icon"}
|
||||
className={
|
||||
"hover:bg-primary/10 text-primary hover:text-primary"
|
||||
}
|
||||
>
|
||||
<Ellipsis className={"size-5"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={openProjectFolder}
|
||||
disabled={removed || loading}
|
||||
>
|
||||
{tc("projects:menuitem:open directory")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeProjectModal.startRemove(project)}
|
||||
disabled={loading}
|
||||
className={"text-destructive focus:text-destructive"}
|
||||
>
|
||||
{tc("projects:remove project")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{removeProjectModal.dialog}
|
||||
{backupProjectModal.dialog}
|
||||
</td>
|
||||
</tr>
|
||||
</ProjectRowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageOrMigrateButton({
|
||||
project,
|
||||
refresh,
|
||||
}: {
|
||||
project: TauriProject;
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
switch (project.project_type) {
|
||||
case "LegacySdk2":
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfExists asChild>
|
||||
<ButtonDisabledIfRemoved variant="success" disabled>
|
||||
{tc("projects:button:migrate")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
</TooltipTriggerIfExists>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:sdk2 migration hint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "LegacyWorlds":
|
||||
case "LegacyAvatars":
|
||||
return <MigrateButton project={project} refresh={refresh} />;
|
||||
case "UpmWorlds":
|
||||
case "UpmAvatars":
|
||||
case "UpmStarter":
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfExists asChild>
|
||||
<ButtonDisabledIfRemoved variant="info" disabled>
|
||||
{tc("projects:button:manage")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
</TooltipTriggerIfExists>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:git-vcc not supported")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "Unknown":
|
||||
case "Worlds":
|
||||
case "Avatars":
|
||||
case "VpmStarter":
|
||||
return (
|
||||
<ButtonDisabledIfRemoved
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/manage?${new URLSearchParams({ projectPath: project.path })}`,
|
||||
)
|
||||
}
|
||||
variant="info"
|
||||
>
|
||||
{tc("projects:button:manage")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function MigrateButton({
|
||||
project,
|
||||
refresh,
|
||||
}: {
|
||||
project: TauriProject;
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
type MigrateState =
|
||||
| {
|
||||
type: "normal";
|
||||
}
|
||||
| {
|
||||
type: "migrateVpm:confirm";
|
||||
}
|
||||
| {
|
||||
type: "migrateVpm:copyingProject";
|
||||
}
|
||||
| {
|
||||
type: "migrateVpm:updating";
|
||||
};
|
||||
|
||||
const [dialogStatus, setDialogStatus] = useState<MigrateState>({
|
||||
type: "normal",
|
||||
});
|
||||
|
||||
const startMigrateVpm = async () => {
|
||||
if (await commands.projectIsUnityLaunching(project.path)) {
|
||||
toastError(tt("projects:toast:close unity before migration"));
|
||||
return;
|
||||
}
|
||||
setDialogStatus({ type: "migrateVpm:confirm" });
|
||||
};
|
||||
|
||||
const doMigrateVpm = async (inPlace: boolean) => {
|
||||
setDialogStatus({ type: "normal" });
|
||||
try {
|
||||
let migrateProjectPath: string;
|
||||
if (inPlace) {
|
||||
migrateProjectPath = project.path;
|
||||
} else {
|
||||
// copy
|
||||
setDialogStatus({ type: "migrateVpm:copyingProject" });
|
||||
migrateProjectPath = await commands.environmentCopyProjectForMigration(
|
||||
project.path,
|
||||
);
|
||||
}
|
||||
setDialogStatus({ type: "migrateVpm:updating" });
|
||||
await commands.projectMigrateProjectToVpm(migrateProjectPath);
|
||||
setDialogStatus({ type: "normal" });
|
||||
toastSuccess(tt("projects:toast:project migrated"));
|
||||
refresh?.();
|
||||
} catch (e) {
|
||||
console.error("Error migrating project", e);
|
||||
setDialogStatus({ type: "normal" });
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
let dialogContent: React.ReactNode = null;
|
||||
switch (dialogStatus.type) {
|
||||
case "migrateVpm:confirm":
|
||||
dialogContent = (
|
||||
<DialogOpen className={"whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:dialog:vpm migrate description")}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setDialogStatus({ type: "normal" })}
|
||||
className="mr-1"
|
||||
>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => doMigrateVpm(false)}
|
||||
variant={"destructive"}
|
||||
className="mr-1"
|
||||
>
|
||||
{tc("projects:button:migrate copy")}
|
||||
</Button>
|
||||
<Button onClick={() => doMigrateVpm(true)} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogOpen>
|
||||
);
|
||||
break;
|
||||
case "migrateVpm:copyingProject":
|
||||
dialogContent = (
|
||||
<DialogOpen className={"whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:pre-migrate copying...")}</p>
|
||||
</DialogDescription>
|
||||
</DialogOpen>
|
||||
);
|
||||
break;
|
||||
case "migrateVpm:updating":
|
||||
dialogContent = (
|
||||
<DialogOpen className={"whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:migrating...")}</p>
|
||||
</DialogDescription>
|
||||
</DialogOpen>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonDisabledIfRemoved variant={"success"} onClick={startMigrateVpm}>
|
||||
{tc("projects:button:migrate")}
|
||||
</ButtonDisabledIfRemoved>
|
||||
{dialogContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// region utilities
|
||||
|
||||
const ProjectRowContext = React.createContext<{
|
||||
removed: boolean;
|
||||
loading: boolean;
|
||||
}>({
|
||||
removed: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const ButtonDisabledIfRemoved = forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(function RemovedButton(props, ref) {
|
||||
const rowContext = useContext(ProjectRowContext);
|
||||
if (rowContext.removed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
{...props}
|
||||
className={`disabled:pointer-events-auto ${props.className}`}
|
||||
disabled
|
||||
ref={ref}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{tt("projects:tooltip:no directory")}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
className={`disabled:pointer-events-auto ${props.className}`}
|
||||
disabled={props.disabled || rowContext.loading || rowContext.removed}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const TooltipTriggerIfRemoved = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof TooltipTrigger>) => {
|
||||
const rowContext = useContext(ProjectRowContext);
|
||||
if (rowContext.removed) {
|
||||
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
|
||||
} else {
|
||||
return children;
|
||||
}
|
||||
};
|
||||
|
||||
const TooltipTriggerIfExists = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof TooltipTrigger>) => {
|
||||
const rowContext = useContext(ProjectRowContext);
|
||||
if (rowContext.removed) {
|
||||
return children;
|
||||
} else {
|
||||
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
|
||||
}
|
||||
};
|
||||
|
||||
function formatDateOffset(date: number): React.ReactNode {
|
||||
const now = Date.now();
|
||||
const diff = now - date;
|
||||
|
||||
const PER_SECOND = 1000;
|
||||
const PER_MINUTE = 60 * PER_SECOND;
|
||||
const PER_HOUR = 60 * PER_MINUTE;
|
||||
const PER_DAY = 24 * PER_HOUR;
|
||||
const PER_WEEK = 7 * PER_DAY;
|
||||
const PER_MONTH = 30 * PER_DAY;
|
||||
const PER_YEAR = 365 * PER_DAY;
|
||||
|
||||
const diffAbs = Math.abs(diff);
|
||||
|
||||
if (diffAbs < PER_MINUTE) return tc("projects:last modified:moments");
|
||||
if (diffAbs < PER_HOUR)
|
||||
return tc("projects:last modified:minutes", {
|
||||
count: Math.floor(diff / PER_MINUTE),
|
||||
});
|
||||
if (diffAbs < PER_DAY)
|
||||
return tc("projects:last modified:hours", {
|
||||
count: Math.floor(diff / PER_HOUR),
|
||||
});
|
||||
if (diffAbs < PER_WEEK)
|
||||
return tc("projects:last modified:days", {
|
||||
count: Math.floor(diff / PER_DAY),
|
||||
});
|
||||
if (diffAbs < PER_MONTH)
|
||||
return tc("projects:last modified:weeks", {
|
||||
count: Math.floor(diff / PER_WEEK),
|
||||
});
|
||||
if (diffAbs < PER_YEAR)
|
||||
return tc("projects:last modified:months", {
|
||||
count: Math.floor(diff / PER_MONTH),
|
||||
});
|
||||
|
||||
return tc("projects:last modified:years", {
|
||||
count: Math.floor(diff / PER_YEAR),
|
||||
});
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { loadLicenses } from "@/lib/licenses";
|
||||
import RenderPage from "./render-client";
|
||||
|
||||
const licenses = await loadLicenses();
|
||||
|
||||
export default function Page() {
|
||||
return <RenderPage licenses={licenses} />;
|
||||
}
|
||||
|
|
@ -1,618 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CheckForUpdateMessage } from "@/components/CheckForUpdateMessage";
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import {
|
||||
BackupFormatSelect,
|
||||
BackupPathWarnings,
|
||||
FilePathRow,
|
||||
LanguageSelector,
|
||||
ProjectPathWarnings,
|
||||
ThemeSelector,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
UnityArgumentsSettings,
|
||||
useUnityArgumentsSettings,
|
||||
} from "@/components/unity-arguments-settings";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
CheckForUpdateResponse,
|
||||
TauriEnvironmentSettings,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import globalInfo, { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import {
|
||||
toastError,
|
||||
toastNormal,
|
||||
toastSuccess,
|
||||
toastThrownError,
|
||||
} from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const result = useQuery({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
queryFn: commands.environmentGetSettings,
|
||||
});
|
||||
|
||||
let body: React.ReactNode;
|
||||
switch (result.status) {
|
||||
case "error":
|
||||
body = <Card className={"p-4"}>{tc("settings:error:load error")}</Card>;
|
||||
break;
|
||||
case "pending":
|
||||
body = <Card className={"p-4"}>{tc("general:loading...")}</Card>;
|
||||
break;
|
||||
case "success":
|
||||
body = <Settings settings={result.data} refetch={result.refetch} />;
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar className={"flex-shrink-0"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
|
||||
{tc("settings")}
|
||||
</p>
|
||||
</HNavBar>
|
||||
{body}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Settings({
|
||||
settings,
|
||||
refetch,
|
||||
}: {
|
||||
settings: TauriEnvironmentSettings;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
|
||||
return (
|
||||
<ScrollPageContainer>
|
||||
<main className="flex flex-col gap-2 flex-shrink flex-grow">
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<h2 className={"pb-2"}>{tc("settings:unity hub path")}</h2>
|
||||
<FilePathRow
|
||||
withoutSelect
|
||||
path={settings.unity_hub}
|
||||
pick={commands.environmentPickUnityHub}
|
||||
refetch={refetch}
|
||||
notFoundMessage={"Unity Hub Not Found"}
|
||||
successMessage={tc("settings:toast:unity hub path updated")}
|
||||
/>
|
||||
</Card>
|
||||
<UnityInstallationsCard
|
||||
refetch={refetch}
|
||||
unityPaths={settings.unity_paths}
|
||||
/>
|
||||
<UnityLaunchArgumentsCard
|
||||
refetch={refetch}
|
||||
unityArgs={settings.default_unity_arguments}
|
||||
/>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<h2>{tc("settings:default project path")}</h2>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("settings:default project path description")}
|
||||
</p>
|
||||
<FilePathRow
|
||||
path={settings.default_project_path}
|
||||
pick={commands.environmentPickProjectDefaultPath}
|
||||
refetch={refetch}
|
||||
successMessage={tc("settings:toast:default project path updated")}
|
||||
/>
|
||||
<ProjectPathWarnings projectPath={settings.default_project_path} />
|
||||
</Card>
|
||||
<BackupCard
|
||||
projectBackupPath={settings.project_backup_path}
|
||||
backupFormat={settings.backup_format}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<PackagesCard
|
||||
showPrereleasePackages={settings.show_prerelease_packages}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<AppearanceCard />
|
||||
<AlcomCard
|
||||
isMac={isMac}
|
||||
releaseChannel={settings.release_channel}
|
||||
useAlcomForVccProtocol={settings.use_alcom_for_vcc_protocol}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<SystemInformationCard />
|
||||
</main>
|
||||
</ScrollPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityInstallationsCard({
|
||||
refetch,
|
||||
unityPaths,
|
||||
}: {
|
||||
refetch: () => void;
|
||||
unityPaths: [path: string, version: string, fromHub: boolean][];
|
||||
}) {
|
||||
const [pickUnity, unityDialog] = useFilePickerFunction(
|
||||
commands.environmentPickUnity,
|
||||
);
|
||||
|
||||
const addUnity = async () => {
|
||||
try {
|
||||
const result = await pickUnity();
|
||||
switch (result) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("settings:toast:not unity"));
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastError(tt("settings:toast:unity already added"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tt("settings:toast:unity added"));
|
||||
refetch();
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const UNITY_TABLE_HEAD = [
|
||||
"settings:unity:version",
|
||||
"settings:unity:path",
|
||||
"general:source",
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<div className={"pb-2 flex align-middle"}>
|
||||
<div className={"flex-grow flex items-center"}>
|
||||
<h2>{tc("settings:unity installations")}</h2>
|
||||
</div>
|
||||
<Button onClick={addUnity} size={"sm"} className={"m-1"}>
|
||||
{tc("settings:button:add unity")}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollableCardTable className="w-full min-h-[20vh]">
|
||||
<thead>
|
||||
<tr>
|
||||
{UNITY_TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unityPaths.map(([path, version, isFromHub]) => (
|
||||
<tr key={path} className="even:bg-secondary/30">
|
||||
<td className={"p-2.5"}>{version}</td>
|
||||
<td className={"p-2.5"}>{path}</td>
|
||||
<td className={"p-2.5"}>
|
||||
{isFromHub
|
||||
? tc("settings:unity:source:unity hub")
|
||||
: tc("settings:unity:source:manual")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
{unityDialog}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityLaunchArgumentsCard({
|
||||
refetch,
|
||||
unityArgs,
|
||||
}: {
|
||||
refetch: () => void;
|
||||
unityArgs: string[] | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const defaultUnityArgs = useGlobalInfo().defaultUnityArguments;
|
||||
const realUnityArgs = unityArgs ?? defaultUnityArgs;
|
||||
|
||||
const close = () => setOpen(false);
|
||||
const openDialog = () => setOpen(true);
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<div className={"pb-2 flex align-middle"}>
|
||||
<div className={"flex-grow flex items-center"}>
|
||||
<h2>{tc("settings:default unity arguments")}</h2>
|
||||
</div>
|
||||
<Button onClick={openDialog} size={"sm"} className={"m-1"}>
|
||||
{tc("general:button:edit")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={"text-sm"}>
|
||||
{tc("settings:default unity arguments description")}
|
||||
</p>
|
||||
<ol className={"flex flex-col"}>
|
||||
{realUnityArgs.map((v, i) => (
|
||||
<Input disabled key={i + v} value={v} className={"w-full"} />
|
||||
))}
|
||||
</ol>
|
||||
{open && (
|
||||
<DialogOpen>
|
||||
<LaunchArgumentsEditDialogBody
|
||||
unityArgs={unityArgs}
|
||||
refetch={refetch}
|
||||
close={close}
|
||||
/>
|
||||
</DialogOpen>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchArgumentsEditDialogBody({
|
||||
unityArgs,
|
||||
refetch,
|
||||
close,
|
||||
}: {
|
||||
unityArgs: string[] | null;
|
||||
refetch: () => void;
|
||||
close: () => void;
|
||||
}) {
|
||||
const context = useUnityArgumentsSettings(
|
||||
unityArgs,
|
||||
globalInfo.defaultUnityArguments,
|
||||
);
|
||||
|
||||
const saveAndClose = async () => {
|
||||
await commands.environmentSetDefaultUnityArguments(context.currentValue);
|
||||
close();
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{tc("settings:dialog:default launch arguments")}
|
||||
</DialogTitle>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
|
||||
<UnityArgumentsSettings context={context} />
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={close} variant={"destructive"}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={saveAndClose} disabled={context.hasError}>
|
||||
{tc("general:button:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupCard({
|
||||
projectBackupPath,
|
||||
backupFormat,
|
||||
refetch,
|
||||
}: {
|
||||
projectBackupPath: string;
|
||||
backupFormat: string;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const setBackupFormat = async (format: string) => {
|
||||
try {
|
||||
await commands.environmentSetBackupFormat(format);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<h2>{tc("projects:backup")}</h2>
|
||||
<div className="mt-2">
|
||||
<h3>{tc("settings:backup:path")}</h3>
|
||||
<p className={"whitespace-normal text-sm"}>
|
||||
{tc("settings:backup:path description")}
|
||||
</p>
|
||||
<FilePathRow
|
||||
path={projectBackupPath}
|
||||
pick={commands.environmentPickProjectBackupPath}
|
||||
refetch={refetch}
|
||||
successMessage={tc("settings:toast:backup path updated")}
|
||||
/>
|
||||
<BackupPathWarnings backupPath={projectBackupPath} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3>{tc("settings:backup:format")}</h3>
|
||||
<p className={"whitespace-normal text-sm"}>
|
||||
{tc("settings:backup:format description")}
|
||||
</p>
|
||||
<label className={"flex items-center"}>
|
||||
<BackupFormatSelect
|
||||
backupFormat={backupFormat}
|
||||
setBackupFormat={setBackupFormat}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PackagesCard({
|
||||
showPrereleasePackages,
|
||||
refetch,
|
||||
}: {
|
||||
showPrereleasePackages: boolean;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const clearPackageCache = async () => {
|
||||
try {
|
||||
await commands.environmentClearPackageCache();
|
||||
toastSuccess(tc("settings:toast:package cache cleared"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowPrereleasePackages = async (e: "indeterminate" | boolean) => {
|
||||
try {
|
||||
await commands.environmentSetShowPrereleasePackages(e === true);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
|
||||
<h2>{tc("settings:packages")}</h2>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button onClick={clearPackageCache}>
|
||||
{tc("settings:clear package cache")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={showPrereleasePackages}
|
||||
onCheckedChange={(e) => toggleShowPrereleasePackages(e)}
|
||||
/>
|
||||
{tc("settings:show prerelease")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:show prerelease description")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceCard() {
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<h2>{tc("settings:appearance")}</h2>
|
||||
<LanguageSelector />
|
||||
<ThemeSelector />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AlcomCard({
|
||||
isMac,
|
||||
releaseChannel,
|
||||
useAlcomForVccProtocol,
|
||||
refetch,
|
||||
}: {
|
||||
isMac: boolean;
|
||||
releaseChannel: string;
|
||||
useAlcomForVccProtocol: boolean;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const [updateState, setUpdateState] = useState<CheckForUpdateResponse | null>(
|
||||
null,
|
||||
);
|
||||
const globalInfo = useGlobalInfo();
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
try {
|
||||
const checkVersion = await commands.utilCheckForUpdate();
|
||||
if (checkVersion) {
|
||||
setUpdateState(checkVersion);
|
||||
} else {
|
||||
toastNormal(tc("check update:toast:no updates"));
|
||||
}
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const reportIssue = async () => {
|
||||
const url = new URL("https://github.com/vrc-get/vrc-get/issues/new");
|
||||
url.searchParams.append("labels", "bug,vrc-get-gui");
|
||||
url.searchParams.append("template", "01_gui_bug-report.yml");
|
||||
url.searchParams.append("os", `${globalInfo.osInfo} - ${globalInfo.arch}`);
|
||||
url.searchParams.append("webview-version", `${globalInfo.webviewVersion}`);
|
||||
let version = globalInfo.version ?? "unknown";
|
||||
if (globalInfo.commitHash) {
|
||||
version += ` (${globalInfo.commitHash})`;
|
||||
} else {
|
||||
version += " (unknown commit)";
|
||||
}
|
||||
url.searchParams.append("version", version);
|
||||
|
||||
void commands.utilOpenUrl(url.toString());
|
||||
};
|
||||
|
||||
const changeReleaseChannel = async (value: "indeterminate" | boolean) => {
|
||||
await commands.environmentSetReleaseChannel(
|
||||
value === true ? "beta" : "stable",
|
||||
);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const changeUseAlcomForVcc = async (value: "indeterminate" | boolean) => {
|
||||
await commands.environmentSetUseAlcomForVccProtocol(value === true);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const installVccProtocol = async () => {
|
||||
try {
|
||||
await commands.deepLinkInstallVcc();
|
||||
toastSuccess(tc("settings:toast:vcc scheme installed"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const openVpmFolderContent = (subPath: string) => {
|
||||
return async () => {
|
||||
try {
|
||||
await commands.utilOpen(
|
||||
`${globalInfo.vpmHomeFolder}/${subPath}`,
|
||||
"ErrorIfNotExists",
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
|
||||
{updateState && (
|
||||
<CheckForUpdateMessage
|
||||
response={updateState}
|
||||
close={() => setUpdateState(null)}
|
||||
/>
|
||||
)}
|
||||
<h2>ALCOM</h2>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button onClick={checkForUpdate}>{tc("settings:check update")}</Button>
|
||||
<Button onClick={reportIssue}>
|
||||
{tc("settings:button:open issue")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button onClick={openVpmFolderContent("settings.json")}>
|
||||
{tc("settings:button:open settings.json")}
|
||||
</Button>
|
||||
<Button onClick={openVpmFolderContent("vrc-get/gui-config.json")}>
|
||||
{tc("settings:button:open gui config.json")}
|
||||
</Button>
|
||||
<Button onClick={openVpmFolderContent("vrc-get/gui-logs")}>
|
||||
{tc("settings:button:open logs")}
|
||||
</Button>
|
||||
<Button onClick={openVpmFolderContent("Templates")}>
|
||||
{tc("settings:button:open custom templates")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={releaseChannel === "beta"}
|
||||
onCheckedChange={(e) => changeReleaseChannel(e)}
|
||||
/>
|
||||
{tc("settings:receive beta updates")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:beta updates description")}
|
||||
</p>
|
||||
</div>
|
||||
{!isMac && (
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={useAlcomForVccProtocol}
|
||||
onCheckedChange={(e) => changeUseAlcomForVcc(e)}
|
||||
/>
|
||||
{tc("settings:use alcom for vcc scheme")}
|
||||
</label>
|
||||
<Button
|
||||
className={"my-1"}
|
||||
disabled={!useAlcomForVccProtocol}
|
||||
onClick={installVccProtocol}
|
||||
>
|
||||
{tc("settings:register vcc scheme now")}
|
||||
</Button>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc([
|
||||
"settings:use vcc scheme description",
|
||||
"settings:vcc scheme description",
|
||||
])}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc(
|
||||
"settings:licenses description",
|
||||
{},
|
||||
{
|
||||
components: {
|
||||
l: <Link href={"/settings/licenses"} className={"underline"} />,
|
||||
},
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemInformationCard() {
|
||||
const info = useGlobalInfo();
|
||||
|
||||
return (
|
||||
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
|
||||
<h2>{tc("settings:system information")}</h2>
|
||||
<dl>
|
||||
<dt>{tc("settings:os")}</dt>
|
||||
<dd className={"ml-8 mb-1"}>{info.osInfo}</dd>
|
||||
<dt>{tc("settings:architecture")}</dt>
|
||||
<dd className={"ml-8 mb-1"}>{info.arch}</dd>
|
||||
<dt>{tc("settings:webview version")}</dt>
|
||||
<dd className={"ml-8 mb-1"}>{info.webviewVersion}</dd>
|
||||
<dt>{tc("settings:alcom version")}</dt>
|
||||
<dd className={"ml-8 mb-1"}>{info.version}</dd>
|
||||
<dt>{tc("settings:alcom commit hash")}</dt>
|
||||
<dd className={"ml-8 mb-1"}>{info.commitHash}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SetupLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"h-screen flex-grow overflow-hidden flex p-4"}>
|
||||
{children}
|
||||
</div>
|
||||
{isDev && <DevTools />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTools() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className={"absolute bottom-0 left-0 p-4 flex flex-col gap-3"}>
|
||||
<p>debug tools</p>
|
||||
<div className={"flex gap-3"}>
|
||||
<button type="button" onClick={() => router.back()}>
|
||||
Go Back
|
||||
</button>
|
||||
<button type="button" onClick={() => router.push("/settings")}>
|
||||
Go Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BackupFormatSelect,
|
||||
BackupPathWarnings,
|
||||
FilePathRow,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { type BodyProps, SetupPageBase } from "../setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:backups:heading")}
|
||||
Body={Body}
|
||||
nextPage={isMac ? "/setup/finish" : "/setup/system-setting"}
|
||||
prevPage={"/setup/project-path"}
|
||||
pageId={"Backups"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ environment, refetch }: BodyProps) {
|
||||
const projectBackupPath = environment.project_backup_path;
|
||||
const backupFormat = environment.backup_format;
|
||||
|
||||
const setBackupFormat = async (format: string) => {
|
||||
try {
|
||||
await commands.environmentSetBackupFormat(format);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{tc("setup:backups:location")}</h3>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("setup:backups:location description")}
|
||||
</CardDescription>
|
||||
<FilePathRow
|
||||
withoutSelect
|
||||
path={projectBackupPath}
|
||||
pick={commands.environmentPickProjectBackupPath}
|
||||
refetch={refetch}
|
||||
successMessage={tc("settings:toast:backup path updated")}
|
||||
/>
|
||||
<BackupPathWarnings backupPath={projectBackupPath} />
|
||||
<div className={"pb-3"} />
|
||||
<h3>{tc("setup:backups:archive")}</h3>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("settings:backup:format description")}
|
||||
</CardDescription>
|
||||
<BackupFormatSelect
|
||||
backupFormat={backupFormat}
|
||||
setBackupFormat={setBackupFormat}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
FilePathRow,
|
||||
ProjectPathWarnings,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { type BodyProps, SetupPageBase } from "../setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:project-path:heading")}
|
||||
Body={Body}
|
||||
nextPage={"/setup/backups"}
|
||||
prevPage={"/setup/unity-hub"}
|
||||
pageId={"ProjectPath"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ environment, refetch }: BodyProps) {
|
||||
return (
|
||||
<>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("setup:project-path:description")}
|
||||
</CardDescription>
|
||||
<FilePathRow
|
||||
withoutSelect
|
||||
path={environment.default_project_path}
|
||||
pick={commands.environmentPickProjectDefaultPath}
|
||||
refetch={refetch}
|
||||
successMessage={tc("settings:toast:default project path updated")}
|
||||
/>
|
||||
<ProjectPathWarnings projectPath={environment.default_project_path} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type BodyProps, SetupPageBase } from "../setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:system-setting:heading")}
|
||||
Body={Body}
|
||||
nextPage={"/setup/finish"}
|
||||
prevPage={"/setup/backups"}
|
||||
pageId={"SystemSetting"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ environment, refetch }: BodyProps) {
|
||||
const useAlcomForVccProtocol = environment.use_alcom_for_vcc_protocol;
|
||||
|
||||
const isBadHostName = useQuery({
|
||||
queryKey: ["util_is_bad_hostname"],
|
||||
queryFn: commands.utilIsBadHostname,
|
||||
initialData: false,
|
||||
});
|
||||
|
||||
const changeUseAlcomForVcc = async (value: "indeterminate" | boolean) => {
|
||||
await commands.environmentSetUseAlcomForVccProtocol(value === true);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMac ? (
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={useAlcomForVccProtocol}
|
||||
onCheckedChange={(e) => changeUseAlcomForVcc(e)}
|
||||
/>
|
||||
{tc("settings:use alcom for vcc scheme")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal text-muted-foreground"}>
|
||||
{tc("setup:system-setting:vcc scheme description")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className={"text-sm whitespace-normal text-muted-foreground"}>
|
||||
{tc("setup:system-setting:macos bug message")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isBadHostName.data && (
|
||||
<div className={"mt-3"}>
|
||||
<p className={"text-sm whitespace-normal text-warning"}>
|
||||
{tc("setup:system-setting:hostname-with-non-ascii")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,25 @@
|
|||
"use client"; // Error components must be Client Components
|
||||
import { useEffect } from "react";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import globalInfo from "@/lib/global-info";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
error: object;
|
||||
reset?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
const errorMessage = `${error}`;
|
||||
const errorStack = `${error.stack}`;
|
||||
// When there is overridden toString, use it. if not, use stringify
|
||||
const errorMessage =
|
||||
error.toString === Object.prototype.toString
|
||||
? JSON.stringify(error)
|
||||
: error.toString();
|
||||
const errorStack =
|
||||
"stack" in error ? `${error.stack}` : "No stacktrace provided";
|
||||
|
||||
const openIssue = () => {
|
||||
try {
|
||||
|
|
@ -48,10 +53,10 @@ export default function ErrorPage({
|
|||
<div className={"w-full flex items-center justify-center"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-xl border bg-card text-card-foreground shadow-sm min-w-[50vw] max-w-[100vw] p-4 flex gap-3"
|
||||
"rounded-xl border bg-card text-card-foreground shadow-xs min-w-[50vw] max-w-[100vw] p-4 flex gap-3"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col flex-grow overflow-hidden"}>
|
||||
<div className={"flex flex-col grow overflow-hidden"}>
|
||||
<h2>Client-side unrecoverable error occurred</h2>
|
||||
<p>This must be a bug! Please report this bug!</p>
|
||||
<div>
|
||||
14
vrc-get-gui/app/-loading.tsx
Normal file
14
vrc-get-gui/app/-loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
export default function Loading({
|
||||
loadingText = "Loading...",
|
||||
}: {
|
||||
loadingText?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full space-y-4">
|
||||
<LoaderCircle className="h-10 w-10 animate-spin" />
|
||||
<p className="text-xl font-semibold text-gray-700">{loadingText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
vrc-get-gui/app/__root.tsx
Normal file
34
vrc-get-gui/app/__root.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import ErrorPage from "@/app/-error";
|
||||
import { Providers } from "@/components/providers";
|
||||
import "./globals.css";
|
||||
import React, { Suspense } from "react";
|
||||
|
||||
const TanStackRouterDevtools = import.meta.env.PROD
|
||||
? () => null // Render nothing in production
|
||||
: React.lazy(() =>
|
||||
// Lazy load in development
|
||||
import("@tanstack/router-devtools").then((res) => ({
|
||||
default: res.TanStackRouterDevtools,
|
||||
// For Embedded Mode
|
||||
// default: res.TanStackRouterDevtoolsPanel
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
errorComponent: ErrorPage,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Providers>
|
||||
<Outlet />
|
||||
</Providers>
|
||||
<Suspense>
|
||||
<TanStackRouterDevtools position={"bottom-right"} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -16,19 +17,29 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toastError, toastInfo, toastNormal, toastSuccess } from "@/lib/toast";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import {
|
||||
toastError,
|
||||
toastInfo,
|
||||
toastNormal,
|
||||
toastSuccess,
|
||||
toastWarning,
|
||||
} from "@/lib/toast";
|
||||
|
||||
export default function Page() {
|
||||
export const Route = createFileRoute("/_main/dev-palette/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar className={"flex-shrink-0"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
|
||||
UI Palette (dev only)
|
||||
</p>
|
||||
</HNavBar>
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={<HNavBarText>UI Palette (dev only)</HNavBarText>}
|
||||
/>
|
||||
<ScrollPageContainer>
|
||||
<main className="flex flex-col gap-2 flex-shrink flex-grow">
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<main className="flex flex-col gap-2 shrink grow">
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<h2 className={"pb-2"}>File Selector</h2>
|
||||
<div className={"flex gap-1 items-center"}>
|
||||
<Input
|
||||
|
|
@ -39,9 +50,9 @@ export default function Page() {
|
|||
<Button className={"flex-none px-4"}>Select</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<div className={"pb-2 flex align-middle"}>
|
||||
<div className={"flex-grow flex items-center"}>
|
||||
<div className={"grow flex items-center"}>
|
||||
<h2>Table</h2>
|
||||
</div>
|
||||
<Button size={"sm"} className={"m-1"}>
|
||||
|
|
@ -52,7 +63,7 @@ export default function Page() {
|
|||
<UnityTableBody />
|
||||
</ScrollableCardTable>
|
||||
</Card>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<h2>Dropdown Selector</h2>
|
||||
<div className="mt-2">
|
||||
<label className={"flex items-center"}>
|
||||
|
|
@ -74,7 +85,7 @@ export default function Page() {
|
|||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<p className={"whitespace-normal"}>Some Description Here</p>
|
||||
<label className={"flex items-center"}>
|
||||
<div className={"p-3"}>
|
||||
|
|
@ -83,7 +94,7 @@ export default function Page() {
|
|||
Checkbox
|
||||
</label>
|
||||
</Card>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<h2 className={"pb-2"}>Buttons</h2>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<Button>Normal</Button>
|
||||
|
|
@ -96,7 +107,7 @@ export default function Page() {
|
|||
<Button variant={"ghost-destructive"}>Ghost Destructive</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={"flex-shrink-0 p-4"}>
|
||||
<Card className={"shrink-0 p-4"}>
|
||||
<h2 className={"pb-2"}>Toasts</h2>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<Button onClick={() => toastNormal("Normal Toast Body")}>
|
||||
|
|
@ -108,6 +119,12 @@ export default function Page() {
|
|||
>
|
||||
Error
|
||||
</Button>
|
||||
<Button
|
||||
variant={"warning"}
|
||||
onClick={() => toastWarning("Warning Toast Body")}
|
||||
>
|
||||
Warning
|
||||
</Button>
|
||||
<Button
|
||||
variant={"success"}
|
||||
onClick={() => toastSuccess("Success Toast Body")}
|
||||
|
|
@ -120,6 +137,14 @@ export default function Page() {
|
|||
>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
variant={"info"}
|
||||
onClick={() =>
|
||||
toastInfo(tc("settings:toast:vcc scheme installed"))
|
||||
}
|
||||
>
|
||||
Info with html inside
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
|
|
@ -160,7 +185,7 @@ function UnityTableBody() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unityPaths.map(([path, version, isFromHub]) => (
|
||||
{unityPaths.map(([path, version, _isFromHub]) => (
|
||||
<tr key={path} className="even:bg-secondary/30">
|
||||
<td className={"p-2.5"}>{version}</td>
|
||||
<td className={"p-2.5"}>{path}</td>
|
||||
136
vrc-get-gui/app/_main/log/-logs-list-card.tsx
Normal file
136
vrc-get-gui/app/_main/log/-logs-list-card.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
|
||||
import { memo, useEffect, useMemo, useRef } from "react";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import type { LogEntry, LogLevel } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
|
||||
export const LogsListCard = memo(function LogsListCard({
|
||||
logEntry,
|
||||
shouldShowLogLevel,
|
||||
search,
|
||||
autoScroll,
|
||||
}: {
|
||||
logEntry: LogEntry[];
|
||||
shouldShowLogLevel: LogLevel[];
|
||||
search: string;
|
||||
autoScroll: boolean;
|
||||
}) {
|
||||
const logsShown = useMemo(
|
||||
() =>
|
||||
logEntry.filter(
|
||||
(log) =>
|
||||
log.message.toLowerCase().includes(search?.toLowerCase() ?? "") &&
|
||||
shouldShowLogLevel.includes(log.level),
|
||||
),
|
||||
[logEntry, search, shouldShowLogLevel],
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: should scroll to the bottom whenever the logsShown changes.
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const isNearBottom =
|
||||
container.scrollHeight - (container.scrollTop + container.clientHeight) <
|
||||
50;
|
||||
|
||||
if (!isNearBottom) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [logsShown, autoScroll]);
|
||||
|
||||
const TABLE_HEAD = ["logs:time", "logs:level", "logs:message"];
|
||||
|
||||
return (
|
||||
<ScrollableCardTable
|
||||
className={"h-full w-full"}
|
||||
viewportRef={scrollContainerRef}
|
||||
>
|
||||
<thead className={"w-full"}>
|
||||
<tr>
|
||||
{TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsShown.map((row) => (
|
||||
<tr key={row.time} className="even:bg-secondary/30">
|
||||
<LogRow log={row} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
);
|
||||
});
|
||||
|
||||
const LogRow = memo(function LogRow({ log }: { log: LogEntry }) {
|
||||
const cellClass = "p-2.5 compact:py-1";
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getFontColorClass = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case "Info":
|
||||
return "";
|
||||
case "Warn":
|
||||
return "text-warning";
|
||||
case "Error":
|
||||
return "text-destructive";
|
||||
case "Debug":
|
||||
return "text-info";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const fontColorClass = getFontColorClass(log.level);
|
||||
const typeIconClass = `${fontColorClass} w-5 h-5`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className={`${cellClass} min-w-32 w-32`}>{formatDate(log.time)}</td>
|
||||
<td className={`${cellClass} min-w-28 w-28`}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex items-center">
|
||||
{log.level === "Info" ? (
|
||||
<Info className={typeIconClass} />
|
||||
) : log.level === "Warn" ? (
|
||||
<OctagonAlert className={typeIconClass} />
|
||||
) : log.level === "Error" ? (
|
||||
<CircleX className={typeIconClass} />
|
||||
) : log.level === "Debug" ? (
|
||||
<BugOff className={typeIconClass} />
|
||||
) : (
|
||||
<Info className={typeIconClass} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className={`font-normal ${fontColorClass}`}>{log.level}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${cellClass} min-w-32 w-full`}>{log.message}</td>
|
||||
</>
|
||||
);
|
||||
});
|
||||
287
vrc-get-gui/app/_main/log/index.tsx
Normal file
287
vrc-get-gui/app/_main/log/index.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowDownFromLine } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
|
||||
import { SearchBox } from "@/components/SearchBox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { LogEntry, LogLevel } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { isFindKey, useDocumentEvent } from "@/lib/events";
|
||||
import globalInfo from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { useTauriListen } from "@/lib/use-tauri-listen";
|
||||
import { useSessionStorage } from "@/lib/useSessionStorage";
|
||||
import { LogsListCard } from "./-logs-list-card";
|
||||
|
||||
export const Route = createFileRoute("/_main/log/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
const utilGetLogEntries = queryOptions({
|
||||
queryKey: ["utilGetLogEntries"],
|
||||
queryFn: async () => commands.utilGetLogEntries(),
|
||||
});
|
||||
|
||||
const environmentLogsLevel = queryOptions({
|
||||
queryKey: ["environmentLogsLevel"],
|
||||
queryFn: async () => commands.environmentLogsLevel(),
|
||||
});
|
||||
|
||||
function Page() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const logEntriesQuery = useQuery(utilGetLogEntries);
|
||||
const logsLevel = useQuery(environmentLogsLevel);
|
||||
|
||||
const handleLogLevelChange = useMutation({
|
||||
mutationFn: async (value: LogLevel[]) =>
|
||||
commands.environmentSetLogsLevel(value),
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(environmentLogsLevel);
|
||||
const data = queryClient.getQueryData(environmentLogsLevel.queryKey);
|
||||
queryClient.setQueryData(environmentLogsLevel.queryKey, value);
|
||||
return data;
|
||||
},
|
||||
onError: (e, _, data) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentLogsLevel.queryKey, data);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentLogsLevel);
|
||||
},
|
||||
});
|
||||
|
||||
const autoScroll = useSessionStorage({
|
||||
key: "logs_auto_scroll",
|
||||
parse: (value) => value === "true",
|
||||
fallbackValue: true,
|
||||
});
|
||||
|
||||
const handleLogAutoScrollChange = (value: boolean) => {
|
||||
sessionStorage.setItem("logs_auto_scroll", String(value));
|
||||
// Manually dispatch storage event to force state synchronization within the same page,
|
||||
// as native sessionStorage.setItem doesn't trigger storage event for the current origin
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: "logs_auto_scroll",
|
||||
newValue: String(value),
|
||||
storageArea: sessionStorage,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
useTauriListen<LogEntry>("log", (event) => {
|
||||
const entry = event.payload as LogEntry;
|
||||
const entries = queryClient.getQueryData(utilGetLogEntries.queryKey) ?? [];
|
||||
queryClient.setQueryData(utilGetLogEntries.queryKey, [...entries, entry]);
|
||||
});
|
||||
|
||||
const shouldShowLogLevel = logsLevel.data ?? [];
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<ManageLogsHeading
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
handleLogLevelChange={handleLogLevelChange.mutate}
|
||||
handleLogAutoScrollChange={handleLogAutoScrollChange}
|
||||
autoScroll={autoScroll}
|
||||
/>
|
||||
<main className="shrink overflow-hidden flex w-full h-full">
|
||||
<LogsListCard
|
||||
logEntry={logEntriesQuery.data ?? []}
|
||||
search={search}
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
autoScroll={autoScroll}
|
||||
/>
|
||||
</main>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageLogsHeading({
|
||||
search,
|
||||
setSearch,
|
||||
shouldShowLogLevel,
|
||||
handleLogLevelChange,
|
||||
handleLogAutoScrollChange,
|
||||
autoScroll,
|
||||
}: {
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
shouldShowLogLevel: LogLevel[];
|
||||
handleLogLevelChange: (newLogLevels: LogLevel[]) => void;
|
||||
handleLogAutoScrollChange: (newAutoScroll: boolean) => void;
|
||||
autoScroll: boolean;
|
||||
}) {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useDocumentEvent(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (isFindKey(e)) {
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={
|
||||
<>
|
||||
<HNavBarText>{tc("logs")}</HNavBarText>
|
||||
|
||||
<SearchBox
|
||||
className={"w-max grow"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
ref={searchRef}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className={"shrink-0 p-3 compact:h-10"}>
|
||||
{tc("logs:manage:select logs level")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Info"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
handleLogLevelChange={handleLogLevelChange}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Warn"
|
||||
className="text-warning"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
handleLogLevelChange={handleLogLevelChange}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Error"
|
||||
className="text-destructive"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
handleLogLevelChange={handleLogLevelChange}
|
||||
/>
|
||||
<LogLevelMenuItem
|
||||
logLevel="Debug"
|
||||
className="text-info"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
handleLogLevelChange={handleLogLevelChange}
|
||||
/>
|
||||
{/* Currently no trace level logs will be passed to frontend */}
|
||||
{/*<LogLevelMenuItem
|
||||
logLevel="Trace"
|
||||
shouldShowLogLevel={shouldShowLogLevel}
|
||||
setShouldShowLogLevel={setShouldShowLogLevel}
|
||||
/>*/}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
className={"compact:h-10"}
|
||||
onClick={() =>
|
||||
commands.utilOpen(
|
||||
`${globalInfo.vpmHomeFolder}/vrc-get/gui-logs`,
|
||||
"ErrorIfNotExists",
|
||||
)
|
||||
}
|
||||
>
|
||||
{tc("settings:button:open logs")}
|
||||
</Button>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
onClick={() => handleLogAutoScrollChange(!autoScroll)}
|
||||
className={`compact:h-10 ${
|
||||
autoScroll
|
||||
? "bg-secondary border border-primary"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<ArrowDownFromLine className={"w-5 h-5"} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tc("logs:manage:auto scroll")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLevelMenuItem({
|
||||
logLevel,
|
||||
className,
|
||||
shouldShowLogLevel,
|
||||
handleLogLevelChange,
|
||||
}: {
|
||||
logLevel: LogLevel;
|
||||
className?: string;
|
||||
shouldShowLogLevel: LogLevel[];
|
||||
handleLogLevelChange: (newLogLevels: LogLevel[]) => void;
|
||||
}) {
|
||||
const selected = shouldShowLogLevel.includes(logLevel);
|
||||
|
||||
const onChange = () => {
|
||||
const newLogLevels = selected
|
||||
? shouldShowLogLevel.filter(
|
||||
(logLevelFilter) => logLevelFilter !== logLevel,
|
||||
)
|
||||
: [...shouldShowLogLevel, logLevel];
|
||||
|
||||
handleLogLevelChange(newLogLevels);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="p-0"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer items-center gap-2 p-2 whitespace-normal"
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onChange}
|
||||
className="hover:before:content-none"
|
||||
/>
|
||||
<p className={className}>{logLevel}</p>
|
||||
</label>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
67
vrc-get-gui/app/_main/packages/-tab-selector.tsx
Normal file
67
vrc-get-gui/app/_main/packages/-tab-selector.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { tc } from "@/lib/i18n";
|
||||
|
||||
type PageType =
|
||||
| "/packages/user-packages"
|
||||
| "/packages/repositories"
|
||||
| "/packages/templates";
|
||||
|
||||
// Note: For historical reasons, templates page are under packages in route.
|
||||
|
||||
export function HeadingPageName({ pageType }: { pageType: PageType }) {
|
||||
// Note for p-1 rounded-md -m-1 compact:m-0
|
||||
// For normal mode, we use 1-unit of the outer padding for selector rectangle, so we use negative margin to eat padding.
|
||||
// For compact mode, the height of the button is 2 units shorter than normal with the height of the navbar is remaining.
|
||||
// Therefore we use the 1 unit space for outer padding for selector rectangle.
|
||||
return (
|
||||
<div className={"flex compact:h-10 items-center"}>
|
||||
<div
|
||||
className={
|
||||
"grid grid-cols-3 gap-1.5 bg-secondary p-1 rounded-md -m-1 compact:m-0"
|
||||
}
|
||||
>
|
||||
<HeadingButton
|
||||
currentPage={pageType}
|
||||
targetPage={"/packages/repositories"}
|
||||
>
|
||||
{tc("packages:repositories")}
|
||||
</HeadingButton>
|
||||
<HeadingButton
|
||||
currentPage={pageType}
|
||||
targetPage={"/packages/user-packages"}
|
||||
>
|
||||
{tc("packages:user packages")}
|
||||
</HeadingButton>
|
||||
<HeadingButton
|
||||
currentPage={pageType}
|
||||
targetPage={"/packages/templates"}
|
||||
>
|
||||
{tc("packages:templates")}
|
||||
</HeadingButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingButton({
|
||||
currentPage,
|
||||
targetPage,
|
||||
children,
|
||||
}: {
|
||||
currentPage: PageType;
|
||||
targetPage: PageType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const button =
|
||||
"cursor-pointer px-3 py-2 font-bold grow-0 hover:bg-background rounded-sm text-center p-2 compact:h-8 compact:py-1";
|
||||
|
||||
if (currentPage === targetPage) {
|
||||
return <div className={`${button} bg-background`}>{children}</div>;
|
||||
} else {
|
||||
return (
|
||||
<Link to={targetPage} className={button}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +1,91 @@
|
|||
import { queryOptions } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ReorderableList,
|
||||
useReorderableList,
|
||||
} from "@/components/ReorderableList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriRemoteRepositoryInfo } from "@/lib/bindings";
|
||||
import type {
|
||||
TauriDuplicatedReason,
|
||||
TauriRemoteRepositoryInfo,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { type DialogApi, type DialogContext, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import type React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastError, toastSuccess } from "@/lib/toast";
|
||||
|
||||
type State =
|
||||
| {
|
||||
type: "normal";
|
||||
}
|
||||
| {
|
||||
type: "enteringRepositoryInfo";
|
||||
}
|
||||
| {
|
||||
type: "loadingRepository";
|
||||
}
|
||||
| {
|
||||
type: "duplicated";
|
||||
}
|
||||
| {
|
||||
type: "confirming";
|
||||
repo: TauriRemoteRepositoryInfo;
|
||||
url: string;
|
||||
headers: { [key: string]: string };
|
||||
};
|
||||
const environmentRepositoriesInfo = queryOptions({
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
});
|
||||
|
||||
interface AddRepository {
|
||||
dialog: React.ReactNode;
|
||||
openAddDialog: () => void;
|
||||
addRepository: (
|
||||
url: string,
|
||||
headers: { [p: string]: string },
|
||||
) => Promise<void>;
|
||||
export async function openAddRepositoryDialog() {
|
||||
using dialog = showDialog();
|
||||
const repoInfo = await dialog.ask(EnteringRepositoryInfo, {});
|
||||
if (repoInfo == null) return;
|
||||
await addRepositoryImpl(dialog, repoInfo.url, repoInfo.headers);
|
||||
}
|
||||
|
||||
export function useAddRepository({
|
||||
refetch,
|
||||
}: {
|
||||
refetch: () => void;
|
||||
}): AddRepository {
|
||||
const [state, setState] = useState<State>({ type: "normal" });
|
||||
|
||||
function cancel() {
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
|
||||
const openAddDialog = useCallback(() => {
|
||||
setState({ type: "enteringRepositoryInfo" });
|
||||
}, []);
|
||||
|
||||
const addRepository = useCallback(async function addRepository(
|
||||
url: string,
|
||||
headers: { [key: string]: string },
|
||||
) {
|
||||
try {
|
||||
setState({ type: "loadingRepository" });
|
||||
const info = await commands.environmentDownloadRepository(url, headers);
|
||||
switch (info.type) {
|
||||
case "BadUrl":
|
||||
toastError(tt("vpm repositories:toast:invalid url"));
|
||||
setState({ type: "normal" });
|
||||
return;
|
||||
case "DownloadError":
|
||||
toastError(
|
||||
tt("vpm repositories:toast:load failed", { message: info.message }),
|
||||
);
|
||||
setState({ type: "normal" });
|
||||
return;
|
||||
case "Duplicated":
|
||||
setState({ type: "duplicated" });
|
||||
return;
|
||||
case "Success":
|
||||
break;
|
||||
default:
|
||||
assertNever(info, "info");
|
||||
}
|
||||
setState({ type: "confirming", repo: info.value, url, headers });
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
let dialogBody: React.ReactNode;
|
||||
switch (state.type) {
|
||||
case "normal":
|
||||
dialogBody = null;
|
||||
break;
|
||||
case "enteringRepositoryInfo":
|
||||
dialogBody = (
|
||||
<EnteringRepositoryInfo
|
||||
cancel={() => setState({ type: "normal" })}
|
||||
addRepository={(url, headers) => addRepository(url, headers)}
|
||||
/>
|
||||
export async function addRepository(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
using dialog = showDialog();
|
||||
await addRepositoryImpl(dialog, url, headers);
|
||||
}
|
||||
async function addRepositoryImpl(
|
||||
dialog: DialogApi,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
dialog.replace(<LoadingRepository cancel={dialog.close} />);
|
||||
const info = await commands.environmentDownloadRepository(url, headers);
|
||||
switch (info.type) {
|
||||
case "BadUrl":
|
||||
toastError(tt("vpm repositories:toast:invalid url"));
|
||||
return;
|
||||
case "DownloadError":
|
||||
toastError(
|
||||
tt("vpm repositories:toast:load failed", {
|
||||
message: info.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "Duplicated":
|
||||
await dialog.askClosing(Duplicated, {
|
||||
reason: info.reason,
|
||||
duplicatedName: info.duplicated_name,
|
||||
});
|
||||
return;
|
||||
case "Success":
|
||||
break;
|
||||
case "loadingRepository":
|
||||
dialogBody = <LoadingRepository cancel={cancel} />;
|
||||
break;
|
||||
case "duplicated":
|
||||
dialogBody = <Duplicated cancel={cancel} />;
|
||||
break;
|
||||
case "confirming": {
|
||||
const doAddRepository = async () => {
|
||||
try {
|
||||
await commands.environmentAddRepository(state.url, state.headers);
|
||||
setState({ type: "normal" });
|
||||
toastSuccess(tt("vpm repositories:toast:repository added"));
|
||||
// noinspection ES6MissingAwait
|
||||
refetch();
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
setState({ type: "normal" });
|
||||
}
|
||||
};
|
||||
dialogBody = (
|
||||
<Confirming
|
||||
repo={state.repo}
|
||||
headers={state.headers}
|
||||
cancel={cancel}
|
||||
add={doAddRepository}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(state, "state");
|
||||
assertNever(info, "info");
|
||||
}
|
||||
if (
|
||||
await dialog.askClosing(Confirming, {
|
||||
repo: info.value,
|
||||
headers: headers,
|
||||
})
|
||||
) {
|
||||
await commands.environmentAddRepository(url, headers);
|
||||
toastSuccess(tt("vpm repositories:toast:repository added"));
|
||||
await queryClient.invalidateQueries(environmentRepositoriesInfo);
|
||||
}
|
||||
|
||||
const dialog = dialogBody ? (
|
||||
<DialogOpen>
|
||||
<DialogTitle>{tc("vpm repositories:button:add repository")}</DialogTitle>
|
||||
{dialogBody}
|
||||
</DialogOpen>
|
||||
) : null;
|
||||
|
||||
return {
|
||||
dialog,
|
||||
addRepository,
|
||||
openAddDialog,
|
||||
};
|
||||
}
|
||||
|
||||
function EnteringRepositoryInfo({
|
||||
cancel,
|
||||
addRepository,
|
||||
dialog,
|
||||
}: {
|
||||
cancel: () => void;
|
||||
addRepository: (url: string, headers: { [name: string]: string }) => void;
|
||||
dialog: DialogContext<null | {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}>;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
|
|
@ -226,12 +151,12 @@ function EnteringRepositoryInfo({
|
|||
if (header.name.trim() === "") continue;
|
||||
headers[header.name.trim()] = header.value.trim();
|
||||
}
|
||||
addRepository(url, headers);
|
||||
dialog.close({ url, headers });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<div>
|
||||
<p className={"font-normal"}>
|
||||
{tc("vpm repositories:dialog:enter repository info")}
|
||||
</p>
|
||||
|
|
@ -270,7 +195,7 @@ function EnteringRepositoryInfo({
|
|||
<Input
|
||||
type={"text"}
|
||||
value={value.name}
|
||||
className={"flex-grow"}
|
||||
className={"grow"}
|
||||
onChange={(e) =>
|
||||
reordableListContext.update(id, (old) => ({
|
||||
...old,
|
||||
|
|
@ -285,7 +210,7 @@ function EnteringRepositoryInfo({
|
|||
<Input
|
||||
type={"text"}
|
||||
value={value.value}
|
||||
className={"flex-grow"}
|
||||
className={"grow"}
|
||||
onChange={(e) =>
|
||||
reordableListContext.update(id, (old) => ({
|
||||
...old,
|
||||
|
|
@ -317,9 +242,11 @@ function EnteringRepositoryInfo({
|
|||
{tc("vpm repositories:hint:duplicate headers")}
|
||||
</p>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onAddRepository}
|
||||
className={"ml-2"}
|
||||
|
|
@ -332,16 +259,12 @@ function EnteringRepositoryInfo({
|
|||
);
|
||||
}
|
||||
|
||||
function LoadingRepository({
|
||||
cancel,
|
||||
}: {
|
||||
cancel: () => void;
|
||||
}) {
|
||||
function LoadingRepository({ cancel }: { cancel: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<div>
|
||||
<p>{tc("vpm repositories:dialog:downloading...")}</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -350,17 +273,44 @@ function LoadingRepository({
|
|||
}
|
||||
|
||||
function Duplicated({
|
||||
cancel,
|
||||
reason,
|
||||
duplicatedName,
|
||||
dialog,
|
||||
}: {
|
||||
cancel: () => void;
|
||||
reason: TauriDuplicatedReason;
|
||||
duplicatedName: string;
|
||||
dialog: DialogContext<void>;
|
||||
}) {
|
||||
const duplicatedDisplayName =
|
||||
duplicatedName === "com.vrchat.repos.curated"
|
||||
? tt("vpm repositories:source:curated")
|
||||
: duplicatedName === "com.vrchat.repos.official"
|
||||
? tt("vpm repositories:source:official")
|
||||
: duplicatedName;
|
||||
let message: React.ReactNode;
|
||||
switch (reason) {
|
||||
case "URLDuplicated":
|
||||
message = tc("vpm repositories:dialog:url duplicated", {
|
||||
name: duplicatedDisplayName,
|
||||
});
|
||||
break;
|
||||
case "IDDuplicated":
|
||||
message = tc("vpm repositories:dialog:id duplicated", {
|
||||
name: duplicatedDisplayName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogDescription>
|
||||
<div>
|
||||
<p>{tc("vpm repositories:dialog:already added")}</p>
|
||||
</DialogDescription>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:ok")}</Button>
|
||||
<Button onClick={() => dialog.close()}>
|
||||
{tc("general:button:ok")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
|
@ -368,19 +318,17 @@ function Duplicated({
|
|||
|
||||
function Confirming({
|
||||
repo,
|
||||
cancel,
|
||||
add,
|
||||
headers,
|
||||
dialog,
|
||||
}: {
|
||||
repo: TauriRemoteRepositoryInfo;
|
||||
headers: { [key: string]: string };
|
||||
cancel: () => void;
|
||||
add: () => void;
|
||||
dialog: DialogContext<boolean>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
|
||||
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("vpm repositories:dialog:name", { name: repo.display_name })}
|
||||
</p>
|
||||
|
|
@ -393,7 +341,7 @@ function Confirming({
|
|||
{tc("vpm repositories:dialog:headers")}
|
||||
</p>
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{Object.entries(headers).map(([key, value], idx) => (
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
{key}: {value}
|
||||
</li>
|
||||
|
|
@ -405,14 +353,16 @@ function Confirming({
|
|||
{tc("vpm repositories:dialog:packages")}
|
||||
</p>
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{repo.packages.map((info, idx) => (
|
||||
{repo.packages.map((info) => (
|
||||
<li key={info.name}>{info.display_name ?? info.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
|
||||
<Button onClick={add} className={"ml-2"}>
|
||||
<Button onClick={() => dialog.close(false)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close(true)} className={"ml-2"}>
|
||||
{tc("vpm repositories:button:add repository")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { queryOptions } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriDownloadRepository,
|
||||
TauriRepositoryDescriptor,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { callAsyncCommand } from "@/lib/call-async-command";
|
||||
import { type DialogContext, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastSuccess } from "@/lib/toast";
|
||||
import { useEffectEvent } from "@/lib/use-effect-event";
|
||||
|
||||
type ParsedRepositories = {
|
||||
repositories: TauriRepositoryDescriptor[];
|
||||
unparsable_lines: string[];
|
||||
};
|
||||
|
||||
const environmentRepositoriesInfo = queryOptions({
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
});
|
||||
|
||||
export async function importRepositories() {
|
||||
using dialog = showDialog();
|
||||
|
||||
const pickResult = await commands.environmentImportRepositoryPick();
|
||||
switch (pickResult.type) {
|
||||
case "NoFilePicked":
|
||||
// no-op
|
||||
return;
|
||||
case "ParsedRepositories":
|
||||
// continue
|
||||
break;
|
||||
default:
|
||||
assertNever(pickResult, "pickResult");
|
||||
}
|
||||
console.log("confirmingRepositories", pickResult);
|
||||
|
||||
const repositories = await dialog.ask(ConfirmingRepositoryList, {
|
||||
pickResult,
|
||||
});
|
||||
if (repositories == null) return;
|
||||
|
||||
const packages = await dialog.ask(LoadingRepositories, {
|
||||
repositories,
|
||||
});
|
||||
if (packages == null) return;
|
||||
|
||||
const repositoriesToAdd = await dialog.ask(ConfirmingPackages, {
|
||||
packages,
|
||||
});
|
||||
if (repositoriesToAdd == null) return;
|
||||
|
||||
dialog.replace(<AddingRepositories />);
|
||||
await commands.environmentImportAddRepositories(repositoriesToAdd);
|
||||
toastSuccess(tt("vpm repositories:toast:repository added"));
|
||||
dialog.close();
|
||||
|
||||
await queryClient.invalidateQueries(environmentRepositoriesInfo);
|
||||
}
|
||||
|
||||
function shortRepositoryDescription(
|
||||
repo: TauriRepositoryDescriptor,
|
||||
): React.ReactNode {
|
||||
if (Object.keys(repo.headers).length > 0) {
|
||||
return tc("vpm repositories:dialog:repository with headers", {
|
||||
repoUrl: repo.url,
|
||||
});
|
||||
}
|
||||
return repo.url;
|
||||
}
|
||||
|
||||
function ConfirmingRepositoryList({
|
||||
pickResult,
|
||||
dialog,
|
||||
}: {
|
||||
pickResult: ParsedRepositories;
|
||||
dialog: DialogContext<TauriRepositoryDescriptor[] | null>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
|
||||
<p className={"font-normal whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm repository list")}
|
||||
</p>
|
||||
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{pickResult.repositories.map((info) => (
|
||||
<li key={info.url}>{shortRepositoryDescription(info)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{pickResult.unparsable_lines.length > 0 && (
|
||||
<>
|
||||
<p className={"font-normal whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:unparsable lines list")}
|
||||
</p>
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{pickResult.unparsable_lines.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: unchanged
|
||||
<li key={idx} className={"whitespace-pre"}>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close(pickResult.repositories)}>
|
||||
{tc("vpm repositories:dialog:button:continue importing repositories")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRepositories({
|
||||
repositories,
|
||||
dialog,
|
||||
}: {
|
||||
repositories: TauriRepositoryDescriptor[];
|
||||
dialog: DialogContext<
|
||||
[TauriRepositoryDescriptor, TauriDownloadRepository][] | null
|
||||
>;
|
||||
}) {
|
||||
const cancelRef = useRef<() => void>(() => {});
|
||||
const totalCount = repositories.length;
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
|
||||
const event = useEffectEvent(() => {
|
||||
const [cancel, resultPromise] = callAsyncCommand(
|
||||
commands.environmentImportDownloadRepositories,
|
||||
[repositories],
|
||||
(downloaded) => setDownloaded(downloaded),
|
||||
);
|
||||
cancelRef.current = cancel;
|
||||
resultPromise.then((x) => dialog.close(x === "cancelled" ? null : x));
|
||||
});
|
||||
|
||||
useEffect(() => event(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p>{tc("vpm repositories:dialog:downloading repositories...")}</p>
|
||||
<Progress value={downloaded} max={totalCount} />
|
||||
<div className={"text-center"}>
|
||||
{tc("vpm repositories:dialog:downloaded n/m", {
|
||||
downloaded,
|
||||
totalCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => cancelRef.current?.()}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmingPackages({
|
||||
packages,
|
||||
dialog,
|
||||
}: {
|
||||
packages: [TauriRepositoryDescriptor, TauriDownloadRepository][];
|
||||
dialog: DialogContext<TauriRepositoryDescriptor[] | null>;
|
||||
}) {
|
||||
async function add() {
|
||||
dialog.close(
|
||||
packages
|
||||
.filter(([_, download]) => download.type === "Success")
|
||||
.map(([repo, _]) => repo),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<div className={"font-normal"}>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm packages list")}
|
||||
</p>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="max-h-[50vh] overflow-y-auto w-full"
|
||||
>
|
||||
{packages.map(([repo, download]) => {
|
||||
let error: boolean;
|
||||
let content: React.ReactNode;
|
||||
switch (download.type) {
|
||||
case "BadUrl":
|
||||
throw new Error("BadUrl should not be here");
|
||||
case "Duplicated":
|
||||
error = true;
|
||||
content = tc(
|
||||
"vpm repositories:dialog:download error:duplicated",
|
||||
);
|
||||
break;
|
||||
case "DownloadError":
|
||||
error = true;
|
||||
content = tc(
|
||||
"vpm repositories:dialog:download error:download error",
|
||||
);
|
||||
break;
|
||||
case "Success":
|
||||
error = false;
|
||||
content = (
|
||||
<ul className={"list-disc pl-6"}>
|
||||
{download.value.packages.map((info) => (
|
||||
<li key={info.name}>{info.display_name ?? info.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
assertNever(download, "download");
|
||||
}
|
||||
const destrucive = error ? "text-destructive" : "";
|
||||
return (
|
||||
<AccordionItem value={repo.url} key={repo.url}>
|
||||
<AccordionTrigger className={`${destrucive} py-2 text-base`}>
|
||||
{shortRepositoryDescription(repo)}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className={destrucive}>
|
||||
{content}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={add} className={"ml-2"}>
|
||||
{tc("vpm repositories:button:add repositories")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddingRepositories() {
|
||||
return (
|
||||
<div>
|
||||
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
889
vrc-get-gui/app/_main/packages/repositories/index.tsx
Normal file
889
vrc-get-gui/app/_main/packages/repositories/index.tsx
Normal file
|
|
@ -0,0 +1,889 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type CollisionDetection,
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
defaultDropAnimation,
|
||||
defaultDropAnimationSideEffects,
|
||||
type Modifier,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ChevronDown, CircleX, GripVertical } from "lucide-react";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TauriUserRepository } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { usePrevPathName } from "@/lib/prev-page";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { useTauriListen } from "@/lib/use-tauri-listen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeadingPageName } from "../-tab-selector";
|
||||
import { addRepository, openAddRepositoryDialog } from "./-use-add-repository";
|
||||
import { importRepositories } from "./-use-import-repositories";
|
||||
|
||||
export const Route = createFileRoute("/_main/packages/repositories/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
type UserRepoWithListId = TauriUserRepository & { listId: string };
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
x: 0,
|
||||
});
|
||||
|
||||
const DRAG_OVERLAY_MODIFIERS = [restrictToVerticalAxis];
|
||||
|
||||
const customDropAnimation: typeof defaultDropAnimation = {
|
||||
...defaultDropAnimation,
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: { opacity: "0" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const TABLE_HEAD = [
|
||||
"", // checkbox
|
||||
"general:name",
|
||||
"vpm repositories:url",
|
||||
"", // actions
|
||||
"", // grip handle
|
||||
] as const;
|
||||
|
||||
const environmentRepositoriesInfo = queryOptions({
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
});
|
||||
|
||||
// Scrolls the given viewport element when the pointer is near the top or bottom
|
||||
// edge during drag. dnd-kit's built-in autoscroll is disabled because it causes
|
||||
// jitter with Radix UI ScrollArea (wrong container detection + double-smoothing).
|
||||
function useDragAutoScroll(
|
||||
viewportRef: React.RefObject<HTMLElement | null>,
|
||||
isActive: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
const THRESHOLD = 80; // px from edge to begin scrolling
|
||||
const MAX_SPEED = 15; // px/frame at the very edge
|
||||
|
||||
let pointerY = 0;
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
pointerY = e.clientY;
|
||||
};
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
const { top, bottom } = viewport.getBoundingClientRect();
|
||||
const distFromTop = pointerY - top;
|
||||
const distFromBottom = bottom - pointerY;
|
||||
|
||||
let delta = 0;
|
||||
if (distFromTop >= 0 && distFromTop < THRESHOLD) {
|
||||
delta = -MAX_SPEED * (1 - distFromTop / THRESHOLD);
|
||||
} else if (distFromBottom >= 0 && distFromBottom < THRESHOLD) {
|
||||
delta = MAX_SPEED * (1 - distFromBottom / THRESHOLD);
|
||||
}
|
||||
|
||||
if (delta !== 0) {
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollTop + delta,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isActive, viewportRef]);
|
||||
}
|
||||
|
||||
function computeSlotKey(repo: TauriUserRepository, used: Set<string>): string {
|
||||
const base = `${repo.id} ${repo.url ?? ""}`;
|
||||
let key = base;
|
||||
let counter = 0;
|
||||
while (used.has(key)) {
|
||||
counter++;
|
||||
key = `${base} ${counter}`;
|
||||
}
|
||||
used.add(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
function PageBody() {
|
||||
const result = useQuery(environmentRepositoriesInfo);
|
||||
|
||||
const exportRepositories = useMutation({
|
||||
mutationFn: async () => await commands.environmentExportRepositories(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const importRepositoriesMutation = useMutation({
|
||||
mutationFn: async () => await importRepositories(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const processDeepLink = useCallback(async function processDeepLink() {
|
||||
const data = await commands.deepLinkTakeAddRepository();
|
||||
if (data == null) return;
|
||||
await addRepository(data.url, data.headers);
|
||||
}, []);
|
||||
|
||||
const hiddenUserRepos = useMemo(
|
||||
() => new Set(result.data?.hidden_user_repositories),
|
||||
[result.data?.hidden_user_repositories],
|
||||
);
|
||||
|
||||
useTauriListen<null>("deep-link-add-repository", (_) => {
|
||||
void processDeepLink();
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to do on mount
|
||||
useEffect(() => {
|
||||
void processDeepLink();
|
||||
// Only for initial load
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const guiAnimation = useQuery({
|
||||
queryKey: ["environmentGuiAnimation"],
|
||||
queryFn: commands.environmentGuiAnimation,
|
||||
initialData: true,
|
||||
}).data;
|
||||
|
||||
const userRepos = result.data?.user_repositories;
|
||||
|
||||
const listIdMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const augmentedUserRepos = useMemo<UserRepoWithListId[]>(() => {
|
||||
if (!userRepos) {
|
||||
listIdMapRef.current = new Map();
|
||||
return [];
|
||||
}
|
||||
const prev = listIdMapRef.current;
|
||||
const next = new Map<string, string>();
|
||||
const usedKeys = new Set<string>();
|
||||
const result: UserRepoWithListId[] = [];
|
||||
|
||||
for (const r of userRepos) {
|
||||
const key = computeSlotKey(r, usedKeys);
|
||||
const listId = prev.get(key) ?? crypto.randomUUID();
|
||||
next.set(key, listId);
|
||||
result.push({ ...r, listId });
|
||||
}
|
||||
|
||||
listIdMapRef.current = next;
|
||||
return result;
|
||||
}, [userRepos]);
|
||||
|
||||
const [orderedListIds, setOrderedListIds] = useState<string[]>(() =>
|
||||
augmentedUserRepos.map((r) => r.listId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedListIds(augmentedUserRepos.map((r) => r.listId));
|
||||
}, [augmentedUserRepos]);
|
||||
|
||||
const userRepoByListId = useMemo(
|
||||
() => new Map(augmentedUserRepos.map((r) => [r.listId, r])),
|
||||
[augmentedUserRepos],
|
||||
);
|
||||
|
||||
const userRepoByListIdRef =
|
||||
useRef<Map<string, UserRepoWithListId>>(userRepoByListId);
|
||||
useEffect(() => {
|
||||
userRepoByListIdRef.current = userRepoByListId;
|
||||
}, [userRepoByListId]);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<number[]>([]);
|
||||
const theadRowRef = useRef<HTMLTableRowElement>(null);
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const orderedListIdsSet = useMemo(
|
||||
() => new Set(orderedListIds),
|
||||
[orderedListIds],
|
||||
);
|
||||
|
||||
const collisionDetection = useCallback<CollisionDetection>(
|
||||
(args) =>
|
||||
closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter((c) =>
|
||||
orderedListIdsSet.has(c.id as string),
|
||||
),
|
||||
}),
|
||||
[orderedListIdsSet],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (listIds: string[]) => {
|
||||
const repos = listIds
|
||||
.map((lid) => userRepoByListId.get(lid))
|
||||
.filter((r): r is UserRepoWithListId => r !== undefined)
|
||||
.map((r) => ({ index: r.index, id: r.id }));
|
||||
return commands.environmentReorderRepositories(repos);
|
||||
},
|
||||
// Pin listIds to the new positions so duplicate-keyed rows don't swap their listIds on refetch.
|
||||
onMutate: (newListIds: string[]) => {
|
||||
const prevMap = new Map(listIdMapRef.current);
|
||||
const rebuilt = new Map<string, string>();
|
||||
const usedKeys = new Set<string>();
|
||||
for (const lid of newListIds) {
|
||||
const repo = userRepoByListIdRef.current.get(lid);
|
||||
if (!repo) continue;
|
||||
const key = computeSlotKey(repo, usedKeys);
|
||||
rebuilt.set(key, lid);
|
||||
}
|
||||
listIdMapRef.current = rebuilt;
|
||||
return { prevMap };
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
|
||||
onError: (e, _newListIds, ctx) => {
|
||||
if (ctx?.prevMap) listIdMapRef.current = ctx.prevMap;
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const setHideRepository = useMutation({
|
||||
mutationFn: async ({ id, shown }: { id: string; shown: boolean }) => {
|
||||
if (shown) {
|
||||
await commands.environmentShowRepository(id);
|
||||
} else {
|
||||
await commands.environmentHideRepository(id);
|
||||
}
|
||||
},
|
||||
onMutate: async ({ id, shown }: { id: string; shown: boolean }) => {
|
||||
await queryClient.cancelQueries(environmentRepositoriesInfo);
|
||||
const data = queryClient.getQueryData(
|
||||
environmentRepositoriesInfo.queryKey,
|
||||
);
|
||||
if (data !== undefined) {
|
||||
let hidden_user_repositories: string[];
|
||||
if (shown) {
|
||||
if (data.hidden_user_repositories.includes(id)) {
|
||||
hidden_user_repositories = data.hidden_user_repositories;
|
||||
} else {
|
||||
hidden_user_repositories = [...data.hidden_user_repositories, id];
|
||||
}
|
||||
} else {
|
||||
hidden_user_repositories = data.hidden_user_repositories.filter(
|
||||
(x) => x !== id,
|
||||
);
|
||||
}
|
||||
|
||||
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
|
||||
...data,
|
||||
hidden_user_repositories,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onError: (e, _, ctx) => {
|
||||
reportError(e);
|
||||
console.error(e);
|
||||
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentRepositoriesInfo);
|
||||
},
|
||||
});
|
||||
|
||||
const activeVisualIndex = useMemo(() => {
|
||||
if (!activeId) return 0;
|
||||
const effectiveId = overId ?? activeId;
|
||||
return orderedListIds.indexOf(effectiveId) + 2; // +2 for the 2 fixed rows
|
||||
}, [activeId, overId, orderedListIds]);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id as string);
|
||||
if (theadRowRef.current) {
|
||||
const widths = Array.from(
|
||||
theadRowRef.current.querySelectorAll("th"),
|
||||
(th) => th.getBoundingClientRect().width,
|
||||
);
|
||||
setColumnWidths(widths);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
setOverId((event.over?.id as string | null) ?? null);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = orderedListIds.indexOf(active.id as string);
|
||||
const newIndex = orderedListIds.indexOf(over.id as string);
|
||||
const newListIds = arrayMove(orderedListIds, oldIndex, newIndex);
|
||||
setOrderedListIds(newListIds);
|
||||
reorderMutation.mutate(newListIds);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
}
|
||||
|
||||
useDragAutoScroll(scrollViewportRef, activeId !== null);
|
||||
|
||||
const bodyAnimation = usePrevPathName().startsWith("/packages")
|
||||
? "slide-right"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
autoScroll={false}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<VStack>
|
||||
<div style={activeId !== null ? { pointerEvents: "none" } : undefined}>
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={<HeadingPageName pageType={"/packages/repositories"} />}
|
||||
trailing={
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<Button
|
||||
className={"rounded-r-none compact:h-10"}
|
||||
onClick={() => openAddRepositoryDialog()}
|
||||
>
|
||||
{tc("vpm repositories:button:add repository")}
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
|
||||
>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => importRepositoriesMutation.mutate()}
|
||||
>
|
||||
{tc("vpm repositories:button:import repositories")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
|
||||
{tc("vpm repositories:button:export repositories")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<main
|
||||
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
|
||||
>
|
||||
<ScrollableCardTable
|
||||
className={"h-full w-full"}
|
||||
viewportRef={scrollViewportRef}
|
||||
>
|
||||
<RepositoryTableBody
|
||||
orderedListIds={orderedListIds}
|
||||
userRepoByListId={userRepoByListId}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
theadRowRef={theadRowRef}
|
||||
guiAnimation={guiAnimation}
|
||||
onToggleVisibility={(id, shown) =>
|
||||
setHideRepository.mutate({ id, shown })
|
||||
}
|
||||
isDragActive={activeId !== null}
|
||||
/>
|
||||
</ScrollableCardTable>
|
||||
</main>
|
||||
</VStack>
|
||||
<DragOverlay
|
||||
modifiers={DRAG_OVERLAY_MODIFIERS}
|
||||
dropAnimation={guiAnimation ? customDropAnimation : null}
|
||||
>
|
||||
{activeId ? (
|
||||
<RepositoryDragOverlay
|
||||
repo={userRepoByListId.get(activeId)}
|
||||
selected={
|
||||
!hiddenUserRepos.has(userRepoByListId.get(activeId)?.id ?? "")
|
||||
}
|
||||
columnWidths={columnWidths}
|
||||
visualIndex={activeVisualIndex}
|
||||
guiAnimation={guiAnimation}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryTableBody({
|
||||
orderedListIds,
|
||||
userRepoByListId,
|
||||
hiddenUserRepos,
|
||||
theadRowRef,
|
||||
guiAnimation,
|
||||
onToggleVisibility,
|
||||
isDragActive,
|
||||
}: {
|
||||
orderedListIds: string[];
|
||||
userRepoByListId: Map<string, UserRepoWithListId>;
|
||||
hiddenUserRepos: Set<string>;
|
||||
theadRowRef: React.RefObject<HTMLTableRowElement | null>;
|
||||
guiAnimation: boolean;
|
||||
onToggleVisibility: (id: string, shown: boolean) => void;
|
||||
isDragActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<thead>
|
||||
<tr ref={theadRowRef}>
|
||||
{TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RepositoryRow
|
||||
repoId={"com.vrchat.repos.official"}
|
||||
url={"https://packages.vrchat.com/official?download"}
|
||||
displayName={tt("vpm repositories:source:official")}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
canRemove={false}
|
||||
rowIndex={0}
|
||||
guiAnimation={guiAnimation}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
<RepositoryRow
|
||||
repoId={"com.vrchat.repos.curated"}
|
||||
url={"https://packages.vrchat.com/curated?download"}
|
||||
displayName={tt("vpm repositories:source:curated")}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
className={"border-b border-primary/10"}
|
||||
canRemove={false}
|
||||
rowIndex={1}
|
||||
guiAnimation={guiAnimation}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
<SortableContext
|
||||
items={orderedListIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedListIds.map((listId, index) => {
|
||||
const repo = userRepoByListId.get(listId);
|
||||
if (!repo) return null;
|
||||
return (
|
||||
<RepositoryRow
|
||||
key={listId}
|
||||
listId={listId}
|
||||
repoId={repo.id}
|
||||
repoIndex={repo.index}
|
||||
displayName={repo.display_name}
|
||||
url={repo.url}
|
||||
hiddenUserRepos={hiddenUserRepos}
|
||||
rowIndex={2 + index}
|
||||
guiAnimation={guiAnimation}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</tbody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CELL_CLASS = "p-2.5 compact:py-1 align-middle";
|
||||
|
||||
function RepositoryRowCells({
|
||||
labelId,
|
||||
displayName,
|
||||
url,
|
||||
canRemove,
|
||||
selected,
|
||||
onCheckedChange,
|
||||
onRemove,
|
||||
dragListeners,
|
||||
dragAttributes,
|
||||
}: {
|
||||
labelId?: string;
|
||||
displayName: string;
|
||||
url: string | null | undefined;
|
||||
canRemove: boolean;
|
||||
selected: boolean;
|
||||
onCheckedChange?: (shown: boolean) => void;
|
||||
onRemove?: () => void;
|
||||
dragListeners?: ReturnType<typeof useSortable>["listeners"];
|
||||
dragAttributes?: ReturnType<typeof useSortable>["attributes"];
|
||||
}) {
|
||||
const interactive = onCheckedChange !== undefined;
|
||||
return (
|
||||
<>
|
||||
<td className={CELL_CLASS}>
|
||||
{interactive ? (
|
||||
<div className="flex">
|
||||
<Checkbox
|
||||
id={labelId}
|
||||
checked={selected}
|
||||
onCheckedChange={(x) => onCheckedChange(x === true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none flex">
|
||||
<Checkbox checked={selected} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={CELL_CLASS}>
|
||||
{interactive ? (
|
||||
<label htmlFor={labelId}>
|
||||
<p className="font-normal">{displayName}</p>
|
||||
</label>
|
||||
) : (
|
||||
<p className="font-normal">{displayName}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className={CELL_CLASS}>
|
||||
<p className="font-normal">{url}</p>
|
||||
</td>
|
||||
<td className={`${CELL_CLASS} w-0`}>
|
||||
{interactive ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={canRemove}>
|
||||
<Button
|
||||
disabled={!canRemove}
|
||||
onClick={onRemove}
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<CircleX className={"size-5 text-destructive"} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{canRemove
|
||||
? tc("vpm repositories:remove repository")
|
||||
: tc(
|
||||
"vpm repositories:tooltip:remove curated or official repository",
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button variant={"ghost"} size={"icon"} disabled>
|
||||
<CircleX className={"size-5 text-destructive"} />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
CELL_CLASS,
|
||||
"w-0",
|
||||
canRemove ? "cursor-move" : "cursor-not-allowed",
|
||||
)}
|
||||
{...(canRemove ? dragListeners : undefined)}
|
||||
{...(canRemove ? dragAttributes : undefined)}
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
"size-5 text-muted-foreground",
|
||||
!canRemove && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryRow({
|
||||
listId,
|
||||
repoId,
|
||||
repoIndex,
|
||||
displayName,
|
||||
url,
|
||||
hiddenUserRepos,
|
||||
className,
|
||||
canRemove = true,
|
||||
rowIndex,
|
||||
guiAnimation,
|
||||
onToggleVisibility,
|
||||
isDragActive,
|
||||
}: {
|
||||
listId?: string;
|
||||
repoId: TauriUserRepository["id"];
|
||||
repoIndex?: number;
|
||||
displayName: TauriUserRepository["display_name"];
|
||||
url: TauriUserRepository["url"];
|
||||
hiddenUserRepos: Set<string>;
|
||||
className?: string;
|
||||
canRemove?: boolean;
|
||||
rowIndex: number;
|
||||
guiAnimation: boolean;
|
||||
onToggleVisibility: (id: string, shown: boolean) => void;
|
||||
isDragActive: boolean;
|
||||
}) {
|
||||
const labelId = useId();
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: listId ?? repoId, disabled: !canRemove });
|
||||
|
||||
const visualIndex = useMemo(() => {
|
||||
if (isDragging) return rowIndex;
|
||||
const dy = transform?.y ?? 0;
|
||||
if (dy < 0) return rowIndex - 1;
|
||||
if (dy > 0) return rowIndex + 1;
|
||||
return rowIndex;
|
||||
}, [rowIndex, transform?.y, isDragging]);
|
||||
|
||||
const dragStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
transform: transform ? `translateY(${transform.y}px)` : undefined,
|
||||
transition: guiAnimation
|
||||
? [transition, isDragActive ? undefined : "background-color 200ms ease"]
|
||||
.filter(Boolean)
|
||||
.join(", ") || undefined
|
||||
: undefined,
|
||||
opacity: isDragging ? 0 : 1,
|
||||
position: "relative",
|
||||
}),
|
||||
[transform, transition, isDragging, guiAnimation, isDragActive],
|
||||
);
|
||||
|
||||
const selected = !hiddenUserRepos.has(repoId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={dragStyle}
|
||||
className={cn(visualIndex % 2 === 1 ? "bg-secondary/30" : "", className)}
|
||||
>
|
||||
<RepositoryRowCells
|
||||
labelId={labelId}
|
||||
displayName={displayName}
|
||||
url={url}
|
||||
canRemove={canRemove}
|
||||
selected={selected}
|
||||
onCheckedChange={(shown) => onToggleVisibility(repoId, shown)}
|
||||
onRemove={() =>
|
||||
void openSingleDialog(RemoveRepositoryDialog, {
|
||||
displayName,
|
||||
index: repoIndex ?? 0,
|
||||
id: repoId,
|
||||
})
|
||||
}
|
||||
dragListeners={listeners}
|
||||
dragAttributes={attributes}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryDragOverlay({
|
||||
repo,
|
||||
selected,
|
||||
columnWidths,
|
||||
visualIndex,
|
||||
guiAnimation,
|
||||
}: {
|
||||
repo: TauriUserRepository | undefined;
|
||||
selected: boolean;
|
||||
columnWidths: number[];
|
||||
visualIndex: number;
|
||||
guiAnimation: boolean;
|
||||
}) {
|
||||
const style = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
transition: guiAnimation ? "background-color 200ms ease" : undefined,
|
||||
}),
|
||||
[guiAnimation],
|
||||
);
|
||||
|
||||
if (!repo) return null;
|
||||
return (
|
||||
<table
|
||||
className={cn(
|
||||
"w-full table-fixed text-left",
|
||||
visualIndex % 2 === 1 ? "bg-secondary/30" : "",
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{columnWidths.length > 0 && (
|
||||
<colgroup>
|
||||
{columnWidths.map((w, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order
|
||||
<col key={i} style={{ width: w }} />
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
<tbody>
|
||||
<tr>
|
||||
<RepositoryRowCells
|
||||
displayName={repo.display_name}
|
||||
url={repo.url}
|
||||
canRemove={true}
|
||||
selected={selected}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveRepositoryDialog({
|
||||
dialog,
|
||||
displayName,
|
||||
index,
|
||||
id,
|
||||
}: {
|
||||
dialog: DialogContext<void>;
|
||||
displayName: string;
|
||||
index: number;
|
||||
id: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeRepository = useMutation({
|
||||
mutationFn: async (args: { index: number; id: string }) =>
|
||||
await commands.environmentRemoveRepository(args.index, args.id),
|
||||
onMutate: async ({ index }) => {
|
||||
await queryClient.cancelQueries(environmentRepositoriesInfo);
|
||||
const data = queryClient.getQueryData(
|
||||
environmentRepositoriesInfo.queryKey,
|
||||
);
|
||||
if (data !== undefined) {
|
||||
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
|
||||
...data,
|
||||
user_repositories: data.user_repositories.filter(
|
||||
(x) => x.index !== index,
|
||||
),
|
||||
});
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onError: (e, _args, ctx) => {
|
||||
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{tc("vpm repositories:remove repository")}</DialogTitle>
|
||||
<div>
|
||||
<p className={"whitespace-normal font-normal"}>
|
||||
{tc("vpm repositories:dialog:confirm remove description", {
|
||||
name: displayName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close()}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
dialog.close();
|
||||
removeRepository.mutate({ index, id });
|
||||
}}
|
||||
className={"ml-2"}
|
||||
>
|
||||
{tc("vpm repositories:remove repository")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1041
vrc-get-gui/app/_main/packages/templates/index.tsx
Normal file
1041
vrc-get-gui/app/_main/packages/templates/index.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CircleX } from "lucide-react";
|
||||
import { Suspense, useId } from "react";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
|
|
@ -20,15 +28,16 @@ import {
|
|||
import type { TauriUserPackage } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { usePrevPathName } from "@/lib/prev-page";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
|
||||
import { toVersionString } from "@/lib/version";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CircleX } from "lucide-react";
|
||||
import { Suspense, useCallback, useId } from "react";
|
||||
import { HeadingPageName } from "../tab-selector";
|
||||
import { HeadingPageName } from "../-tab-selector";
|
||||
|
||||
export default function Page() {
|
||||
export const Route = createFileRoute("/_main/packages/user-packages/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PageBody />
|
||||
|
|
@ -36,80 +45,101 @@ export default function Page() {
|
|||
);
|
||||
}
|
||||
|
||||
const environmentGetUserPackages = queryOptions({
|
||||
queryKey: ["environmentGetUserPackages"],
|
||||
queryFn: commands.environmentGetUserPackages,
|
||||
});
|
||||
|
||||
function PageBody() {
|
||||
const result = useQuery({
|
||||
queryKey: ["environmentGetUserPackages"],
|
||||
queryFn: commands.environmentGetUserPackages,
|
||||
const result = useQuery(environmentGetUserPackages);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const addUserPackageWithPicker = useMutation({
|
||||
mutationFn: async () =>
|
||||
await commands.environmentAddUserPackageWithPicker(),
|
||||
onSuccess: async (result) => {
|
||||
switch (result) {
|
||||
case "NoFolderSelected":
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("user packages:toast:invalid selection"));
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastSuccess(tc("user packages:toast:package already added"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("user packages:toast:package added"));
|
||||
await queryClient.invalidateQueries(environmentGetUserPackages);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toastThrownError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const [envAddUserPackage, dialog] = useFilePickerFunction(
|
||||
commands.environmentAddUserPackageWithPicker,
|
||||
);
|
||||
|
||||
const addUserPackage = useCallback(
|
||||
async function addUserPackage() {
|
||||
try {
|
||||
switch (await envAddUserPackage()) {
|
||||
case "NoFolderSelected":
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("user packages:toast:invalid selection"));
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastSuccess(tc("user packages:toast:package already added"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("user packages:toast:package added"));
|
||||
await result.refetch();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
}
|
||||
},
|
||||
[envAddUserPackage, result],
|
||||
);
|
||||
|
||||
const removeUserPackage = useCallback(
|
||||
async function removeUserPackage(path: string) {
|
||||
try {
|
||||
await commands.environmentRemoveUserPackages(path);
|
||||
toastSuccess(tc("user packages:toast:package removed"));
|
||||
await result.refetch();
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
}
|
||||
},
|
||||
[result],
|
||||
);
|
||||
const bodyAnimation = usePrevPathName().startsWith("/packages")
|
||||
? "slide-left"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar className={"flex-shrink-0"}>
|
||||
<HeadingPageName pageType={"/packages/user-packages"} />
|
||||
<div className={"flex-grow"} />
|
||||
<Button onClick={addUserPackage}>
|
||||
{tc("user packages:button:add package")}
|
||||
</Button>
|
||||
</HNavBar>
|
||||
<ScrollableCardTable>
|
||||
<RepositoryTableBody
|
||||
userPackages={result.data || []}
|
||||
removeUserPackage={removeUserPackage}
|
||||
/>
|
||||
</ScrollableCardTable>
|
||||
{dialog}
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={<HeadingPageName pageType={"/packages/user-packages"} />}
|
||||
trailing={
|
||||
<Button
|
||||
className={"compact:h-10"}
|
||||
onClick={() => addUserPackageWithPicker.mutate()}
|
||||
>
|
||||
{tc("user packages:button:add package")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
|
||||
>
|
||||
<ScrollableCardTable className={"h-full w-full"}>
|
||||
<RepositoryTableBody userPackages={result.data || []} />
|
||||
</ScrollableCardTable>
|
||||
</main>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryTableBody({
|
||||
userPackages,
|
||||
removeUserPackage,
|
||||
}: {
|
||||
userPackages: TauriUserPackage[];
|
||||
removeUserPackage: (path: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const removeUserPackages = useMutation({
|
||||
mutationFn: async (path: string) =>
|
||||
await commands.environmentRemoveUserPackages(path),
|
||||
onMutate: async (path) => {
|
||||
await queryClient.invalidateQueries(environmentGetUserPackages);
|
||||
const data = queryClient.getQueryData(
|
||||
environmentGetUserPackages.queryKey,
|
||||
);
|
||||
if (data !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
environmentGetUserPackages.queryKey,
|
||||
data.filter((x) => x.path === path),
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onError: (error, _, ctx) => {
|
||||
console.error(error);
|
||||
toastThrownError(error);
|
||||
queryClient.setQueryData(environmentGetUserPackages.queryKey, ctx);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetUserPackages);
|
||||
},
|
||||
});
|
||||
|
||||
const TABLE_HEAD = [
|
||||
"general:name",
|
||||
"user packages:path",
|
||||
|
|
@ -126,7 +156,7 @@ function RepositoryTableBody({
|
|||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
|
|
@ -139,7 +169,7 @@ function RepositoryTableBody({
|
|||
<PackageRow
|
||||
key={pkg.path}
|
||||
pkg={pkg}
|
||||
remove={() => removeUserPackage(pkg.path)}
|
||||
remove={() => removeUserPackages.mutate(pkg.path)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -154,7 +184,7 @@ function PackageRow({
|
|||
pkg: TauriUserPackage;
|
||||
remove: () => void;
|
||||
}) {
|
||||
const cellClass = "p-2.5";
|
||||
const cellClass = "p-2.5 compact:py-1";
|
||||
const id = useId();
|
||||
|
||||
const pkgDisplayNames = pkg.package.display_name ?? pkg.package.name;
|
||||
|
|
@ -189,14 +219,14 @@ function PackageRow({
|
|||
<DialogTitle>
|
||||
{tc("user packages:dialog:remove package")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div>
|
||||
<p className={"whitespace-normal font-normal"}>
|
||||
{tc("user packages:dialog:confirm remove description", {
|
||||
name: pkgDisplayNames,
|
||||
path: pkg.path,
|
||||
})}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>{tc("general:button:cancel")}</Button>
|
||||
325
vrc-get-gui/app/_main/projects/-create-project.tsx
Normal file
325
vrc-get-gui/app/_main/projects/-create-project.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { VStack } from "@/components/layout";
|
||||
import { TemplateSelect } from "@/components/TemplateSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriProjectTemplateInfo } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { type DialogContext, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { router } from "@/lib/main";
|
||||
import { pathSeparator } from "@/lib/os";
|
||||
import {
|
||||
ProjectNameCheckResult,
|
||||
useProjectNameCheck,
|
||||
} from "@/lib/project-name-check";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
|
||||
export async function createProject() {
|
||||
const information = await commands.environmentProjectCreationInformation();
|
||||
|
||||
using dialog = showDialog();
|
||||
const result = await dialog.ask(EnteringInformation, {
|
||||
templates: information.templates,
|
||||
favoriteTemplates: information.favorite_templates,
|
||||
lastUsedTemplate: information.last_used_template,
|
||||
projectLocation: information.default_path,
|
||||
recentProjectLocations: information.recent_project_locations,
|
||||
});
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
dialog.replace(<CreatingProject />);
|
||||
|
||||
await commands.environmentCreateProject(
|
||||
result.projectLocation,
|
||||
result.projectName,
|
||||
result.templateId,
|
||||
information.templates_version,
|
||||
result.unityVersion,
|
||||
);
|
||||
dialog.close();
|
||||
toastSuccess(tt("projects:toast:project created"));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentProjects"],
|
||||
});
|
||||
const projectPath = `${result.projectLocation}${pathSeparator()}${result.projectName}`;
|
||||
router.navigate({
|
||||
to: "/projects/manage",
|
||||
search: { projectPath },
|
||||
});
|
||||
}
|
||||
|
||||
function DialogBase({
|
||||
children,
|
||||
close,
|
||||
createProject,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
close?: () => void;
|
||||
createProject?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{tc("projects:create new project")}</DialogTitle>
|
||||
<div>{children}</div>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={close} disabled={!close}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={createProject} disabled={!createProject}>
|
||||
{tc("projects:button:create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectCreationInformation {
|
||||
templateId: string;
|
||||
unityVersion: string;
|
||||
projectLocation: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
function EnteringInformation({
|
||||
templates,
|
||||
projectLocation: projectLocationFirst,
|
||||
recentProjectLocations: recentProjectLocationsReversed,
|
||||
favoriteTemplates,
|
||||
lastUsedTemplate,
|
||||
dialog,
|
||||
}: {
|
||||
templates: TauriProjectTemplateInfo[];
|
||||
projectLocation: string;
|
||||
favoriteTemplates: string[];
|
||||
lastUsedTemplate: string | null;
|
||||
recentProjectLocations: string[];
|
||||
dialog: DialogContext<null | ProjectCreationInformation>;
|
||||
}) {
|
||||
const templateById = useMemo(
|
||||
() => new Map(templates.map((t) => [t.id, t])),
|
||||
[templates],
|
||||
);
|
||||
|
||||
const [templateId, setTemplateId] = useState<string>(() => {
|
||||
const template = lastUsedTemplate
|
||||
? templateById.get(lastUsedTemplate)
|
||||
: undefined;
|
||||
return template?.available &&
|
||||
template.unity_versions.length !== 0 &&
|
||||
lastUsedTemplate != null
|
||||
? lastUsedTemplate
|
||||
: templates[0].id;
|
||||
});
|
||||
|
||||
const [unityVersion, setUnityVersion] = useState<string>(
|
||||
() =>
|
||||
templateById.get(templateId)?.unity_versions?.[0] ??
|
||||
templates[0].unity_versions[0],
|
||||
);
|
||||
|
||||
const [projectNameRaw, setProjectName] = useState("New Project");
|
||||
const projectName = projectNameRaw.trim();
|
||||
const [projectLocation, setProjectLocation] = useState(projectLocationFirst);
|
||||
const [lastPickedLocation, setLastPickedLocation] =
|
||||
useState(projectLocationFirst);
|
||||
const projectNameCheckState = useProjectNameCheck(
|
||||
projectLocation,
|
||||
projectName,
|
||||
);
|
||||
|
||||
const usePickProjectDefaultPath = useMutation({
|
||||
mutationFn: () => commands.environmentPickProjectDefaultPath(),
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
setProjectLocation(result.new_path);
|
||||
setLastPickedLocation(result.new_path);
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const createProject = async () => {
|
||||
dialog.close({
|
||||
templateId,
|
||||
unityVersion,
|
||||
projectLocation,
|
||||
projectName,
|
||||
});
|
||||
};
|
||||
|
||||
const templateInputId = useId();
|
||||
const unityInputId = useId();
|
||||
|
||||
const unityVersions = templateById.get(templateId)?.unity_versions ?? [];
|
||||
|
||||
const badProjectName = ["AlreadyExists", "InvalidNameForFolderName"].includes(
|
||||
projectNameCheckState,
|
||||
);
|
||||
|
||||
const canCreateProject =
|
||||
projectNameCheckState !== "checking" && !badProjectName;
|
||||
|
||||
useEffect(() => {
|
||||
setUnityVersion(unityVersions[0]);
|
||||
}, [unityVersions]);
|
||||
|
||||
const recentProjectLocations = useMemo(() => {
|
||||
const copied = [...recentProjectLocationsReversed];
|
||||
copied.reverse();
|
||||
return copied;
|
||||
}, [recentProjectLocationsReversed]);
|
||||
|
||||
return (
|
||||
<DialogBase
|
||||
close={() => dialog.close(null)}
|
||||
createProject={canCreateProject ? createProject : undefined}
|
||||
>
|
||||
<VStack>
|
||||
<div className={"flex gap-1"}>
|
||||
<div className={"flex items-center whitespace-nowrap"}>
|
||||
<label htmlFor={templateInputId}>{tc("projects:template")}</label>
|
||||
</div>
|
||||
<TemplateSelect
|
||||
value={templateId}
|
||||
onValueChange={setTemplateId}
|
||||
templates={templates}
|
||||
favoriteTemplates={favoriteTemplates}
|
||||
selectTriggerId={templateInputId}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex items-center gap-1 whitespace-nowrap"}>
|
||||
<label htmlFor={unityInputId}>
|
||||
{tc("projects:template:unity version")}
|
||||
</label>
|
||||
<Select
|
||||
value={unityVersion}
|
||||
onValueChange={(value) => setUnityVersion(value)}
|
||||
disabled={unityVersions.length === 1}
|
||||
>
|
||||
<SelectTrigger id={unityInputId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unityVersions.map((unityVersion) => (
|
||||
<SelectItem value={unityVersion} key={unityVersion}>
|
||||
<UnityVersion
|
||||
unityVersion={unityVersion}
|
||||
latestUnityVersion={
|
||||
unityVersions.length === 1 ? "" : unityVersions[0]
|
||||
}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
value={projectNameRaw}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
<div className={"flex gap-1 items-center"}>
|
||||
{/*Note that this is an abuse of Select*/}
|
||||
<Select value={""} onValueChange={(v) => setProjectLocation(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={projectLocation} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!recentProjectLocations.includes(lastPickedLocation) && (
|
||||
<SelectItem value={lastPickedLocation}>
|
||||
{lastPickedLocation}
|
||||
</SelectItem>
|
||||
)}
|
||||
{recentProjectLocations.map((path) => (
|
||||
<SelectItem value={path} key={path}>
|
||||
{path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="flex-none px-4"
|
||||
onClick={() => usePickProjectDefaultPath.mutate()}
|
||||
>
|
||||
{tc("general:button:select")}
|
||||
</Button>
|
||||
</div>
|
||||
<small className={"whitespace-normal"}>
|
||||
{tc(
|
||||
"projects:hint:path of creating project",
|
||||
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
|
||||
{
|
||||
components: {
|
||||
path: (
|
||||
<span
|
||||
className={
|
||||
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
<ProjectNameCheckResult projectNameCheckState={projectNameCheckState} />
|
||||
</VStack>
|
||||
</DialogBase>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityVersion({
|
||||
unityVersion,
|
||||
latestUnityVersion,
|
||||
}: {
|
||||
unityVersion: string;
|
||||
latestUnityVersion: string;
|
||||
}) {
|
||||
if (unityVersion === latestUnityVersion) {
|
||||
return (
|
||||
<>
|
||||
{unityVersion}{" "}
|
||||
<span className={"text-success"}>{tc("projects:latest")}</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return unityVersion;
|
||||
}
|
||||
}
|
||||
|
||||
function CreatingProject() {
|
||||
return (
|
||||
<DialogBase>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<RefreshCw className={"w-5 h-5 animate-spin"} />
|
||||
<p>{tc("projects:creating project...")}</p>
|
||||
</div>
|
||||
</DialogBase>
|
||||
);
|
||||
}
|
||||
235
vrc-get-gui/app/_main/projects/-project-grid-item.tsx
Normal file
235
vrc-get-gui/app/_main/projects/-project-grid-item.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
|
||||
import {
|
||||
ButtonDisabledIfInvalid,
|
||||
getProjectDisplayInfo,
|
||||
ManageOrMigrateButton,
|
||||
ProjectContext,
|
||||
TooltipTriggerIfInvalid,
|
||||
TooltipTriggerIfValid,
|
||||
useSetProjectFavoriteMutation,
|
||||
} from "@/app/_main/projects/-project-row";
|
||||
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
|
||||
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
|
||||
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
|
||||
import { OpenUnityButton } from "@/components/OpenUnityButton";
|
||||
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TauriProject } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import {
|
||||
dateToString,
|
||||
dayToString,
|
||||
formatDateOffset,
|
||||
} from "@/lib/dateToString";
|
||||
import { openSingleDialog } from "@/lib/dialog";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
|
||||
export function ProjectGridItem({
|
||||
project,
|
||||
loading,
|
||||
}: {
|
||||
project: TauriProject;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const setProjectFavorite = useSetProjectFavoriteMutation();
|
||||
|
||||
const typeIconClass = "w-5 h-5";
|
||||
|
||||
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
|
||||
getProjectDisplayInfo(project);
|
||||
|
||||
const removed = !project.is_exists;
|
||||
const is_valid = project.is_valid;
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider
|
||||
value={{ removed, is_valid, loading: Boolean(loading) }}
|
||||
>
|
||||
<Card className="relative p-4 bg-card flex flex-col gap-2 group compact:p-2 compact:pl-3 compact:gap-1">
|
||||
<div className={"absolute top-2 right-2 gap-2 flex"}>
|
||||
<div className="relative content-center">
|
||||
<FavoriteStarToggleButton
|
||||
favorite={project.favorite}
|
||||
disabled={removed || loading}
|
||||
onToggle={() =>
|
||||
setProjectFavorite.mutate({
|
||||
...project,
|
||||
favorite: !project.favorite,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
commands.utilOpen(project.path, "ErrorIfNotExists")
|
||||
}
|
||||
disabled={!project.is_exists || loading}
|
||||
>
|
||||
{tc("projects:menuitem:open directory")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyProject(project.path);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
}}
|
||||
disabled={!project.is_valid}
|
||||
>
|
||||
{tc("projects:menuitem:copy project")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
openSingleDialog(RemoveProjectDialog, { project })
|
||||
}
|
||||
disabled={loading}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{tc("projects:remove project")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfInvalid
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfValid
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<p className="font-normal whitespace-pre overflow-ellipsis overflow-hidden">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="font-normal opacity-50 text-sm whitespace-pre overflow-ellipsis overflow-hidden compact:hidden">
|
||||
{project.path}
|
||||
</p>
|
||||
</TooltipTriggerIfValid>
|
||||
<TooltipContent>{project.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipTriggerIfInvalid>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{removed
|
||||
? tc("projects:tooltip:no directory")
|
||||
: tc("projects:tooltip:invalid project")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex items-center">
|
||||
{projectTypeKind === "avatars" ? (
|
||||
<CircleUserRound className={typeIconClass} />
|
||||
) : projectTypeKind === "worlds" ? (
|
||||
<Globe className={typeIconClass} />
|
||||
) : (
|
||||
<CircleHelp className={typeIconClass} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="font-normal">{displayType}</p>
|
||||
{isLegacy && (
|
||||
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
|
||||
{tc("projects:type:legacy")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm flex flex-col justify-center">·</p>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className={"text-sm"}>{project.unity}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tc("general:created at")}:{" "}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<time dateTime={createdAt.toISOString()}>
|
||||
<time className="font-normal">
|
||||
{dayToString(project.created_at)}
|
||||
</time>
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{dateToString(project.created_at)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">/</p>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tc("general:last modified")}:{" "}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<time dateTime={lastModified.toISOString()}>
|
||||
<time className="font-normal">
|
||||
{formatDateOffset(project.last_modified)}
|
||||
</time>
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{dateToString(project.last_modified)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 justify-end compact:gap-1">
|
||||
<ButtonDisabledIfInvalid asChild>
|
||||
<OpenUnityButton
|
||||
projectPath={project.path}
|
||||
unityVersion={project.unity}
|
||||
unityRevision={project.unity_revision}
|
||||
/>
|
||||
</ButtonDisabledIfInvalid>
|
||||
<ManageOrMigrateButton project={project} />
|
||||
<ButtonDisabledIfInvalid
|
||||
onClick={() =>
|
||||
openSingleDialog(BackupProjectDialog, {
|
||||
projectPath: project.path,
|
||||
})
|
||||
}
|
||||
variant="success"
|
||||
>
|
||||
{tc("projects:backup")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
</div>
|
||||
</Card>
|
||||
</ProjectContext.Provider>
|
||||
);
|
||||
}
|
||||
572
vrc-get-gui/app/_main/projects/-project-row.tsx
Normal file
572
vrc-get-gui/app/_main/projects/-project-row.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
|
||||
import React, { type ComponentProps, useContext } from "react";
|
||||
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
|
||||
import { MigrationCopyingDialog } from "@/app/_main/projects/manage/-unity-migration";
|
||||
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
|
||||
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
|
||||
import { OpenUnityButton } from "@/components/OpenUnityButton";
|
||||
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import {
|
||||
dateToString,
|
||||
dayToString,
|
||||
formatDateOffset,
|
||||
} from "@/lib/dateToString";
|
||||
import { type DialogContext, openSingleDialog, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { router } from "@/lib/main";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { compareUnityVersionString } from "@/lib/version";
|
||||
|
||||
export const ProjectDisplayType: Record<
|
||||
TauriProjectType,
|
||||
"avatars" | "worlds" | "sdk2" | "unknown"
|
||||
> = {
|
||||
Unknown: "unknown",
|
||||
LegacySdk2: "sdk2",
|
||||
LegacyWorlds: "worlds",
|
||||
LegacyAvatars: "avatars",
|
||||
UpmWorlds: "worlds",
|
||||
UpmAvatars: "avatars",
|
||||
UpmStarter: "unknown",
|
||||
Worlds: "worlds",
|
||||
Avatars: "avatars",
|
||||
VpmStarter: "unknown",
|
||||
};
|
||||
|
||||
export const LegacyProjectTypes = [
|
||||
"LegacySdk2",
|
||||
"LegacyWorlds",
|
||||
"LegacyAvatars",
|
||||
"UpmWorlds",
|
||||
"UpmAvatars",
|
||||
"UpmStarter",
|
||||
];
|
||||
|
||||
const environmentProjects = queryOptions({
|
||||
queryKey: ["environmentProjects"],
|
||||
queryFn: commands.environmentProjects,
|
||||
});
|
||||
|
||||
export function ProjectRow({
|
||||
project,
|
||||
loading,
|
||||
}: {
|
||||
project: TauriProject;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const cellClass = "p-2.5 compact:py-1";
|
||||
const noGrowCellClass = `${cellClass} w-1`;
|
||||
const typeIconClass = "w-5 h-5";
|
||||
|
||||
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
|
||||
getProjectDisplayInfo(project);
|
||||
|
||||
const openProjectFolder = () =>
|
||||
commands.utilOpen(project.path, "ErrorIfNotExists");
|
||||
|
||||
const onCopyProject = async () => {
|
||||
try {
|
||||
await copyProject(project.path);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const setProjectFavorite = useSetProjectFavoriteMutation();
|
||||
|
||||
const removed = !project.is_exists;
|
||||
const is_valid = project.is_valid;
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider
|
||||
value={{ removed, is_valid, loading: Boolean(loading) }}
|
||||
>
|
||||
<tr
|
||||
className={`group even:bg-secondary/30 ${removed || loading || !(project.is_valid ?? true) ? "opacity-50" : ""}`}
|
||||
>
|
||||
<td className={noGrowCellClass}>
|
||||
<div className={"relative flex"}>
|
||||
<FavoriteStarToggleButton
|
||||
favorite={project.favorite}
|
||||
disabled={removed || loading}
|
||||
onToggle={() =>
|
||||
setProjectFavorite.mutate({
|
||||
...project,
|
||||
favorite: !project.favorite,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${cellClass} max-w-64 overflow-hidden`}>
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfInvalid
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfValid
|
||||
className={"text-left select-text cursor-auto w-full"}
|
||||
>
|
||||
<p className="font-normal whitespace-pre">{project.name}</p>
|
||||
<p className="font-normal opacity-50 text-sm whitespace-pre compact:hidden">
|
||||
{project.path}
|
||||
</p>
|
||||
</TooltipTriggerIfValid>
|
||||
<TooltipContent>{project.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipTriggerIfInvalid>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{removed
|
||||
? tc("projects:tooltip:no directory")
|
||||
: tc("projects:tooltip:invalid project")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex items-center">
|
||||
{projectTypeKind === "avatars" ? (
|
||||
<CircleUserRound className={typeIconClass} />
|
||||
) : projectTypeKind === "worlds" ? (
|
||||
<Globe className={typeIconClass} />
|
||||
) : (
|
||||
<CircleHelp className={typeIconClass} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="font-normal">{displayType}</p>
|
||||
{isLegacy && (
|
||||
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
|
||||
{tc("projects:type:legacy")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<p className="font-normal">{project.unity}</p>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<time dateTime={createdAt.toISOString()}>
|
||||
<time className="font-normal">
|
||||
{dayToString(project.created_at)}
|
||||
</time>
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{dateToString(project.created_at)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<time dateTime={lastModified.toISOString()}>
|
||||
<time className="font-normal">
|
||||
{formatDateOffset(project.last_modified)}
|
||||
</time>
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{dateToString(project.last_modified)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className={noGrowCellClass}>
|
||||
<div className="flex flex-row gap-2 max-w-min items-center">
|
||||
<ButtonDisabledIfInvalid asChild>
|
||||
<OpenUnityButton
|
||||
projectPath={project.path}
|
||||
unityVersion={project.unity}
|
||||
unityRevision={project.unity_revision}
|
||||
/>
|
||||
</ButtonDisabledIfInvalid>
|
||||
<ManageOrMigrateButton project={project} />
|
||||
<ButtonDisabledIfInvalid
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openSingleDialog(BackupProjectDialog, {
|
||||
projectPath: project.path,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
}}
|
||||
variant={"success"}
|
||||
>
|
||||
{tc("projects:backup")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={"icon"}
|
||||
className={
|
||||
"hover:bg-primary/10 text-primary hover:text-primary"
|
||||
}
|
||||
>
|
||||
<Ellipsis className={"size-5"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={openProjectFolder}
|
||||
disabled={removed || loading}
|
||||
>
|
||||
{tc("projects:menuitem:open directory")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onCopyProject}
|
||||
disabled={removed || !(is_valid ?? true)}
|
||||
>
|
||||
{tc("projects:menuitem:copy project")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
openSingleDialog(RemoveProjectDialog, { project })
|
||||
}
|
||||
disabled={loading}
|
||||
className={"text-destructive focus:text-destructive"}
|
||||
>
|
||||
{tc("projects:remove project")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ProjectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManageOrMigrateButton({ project }: { project: TauriProject }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (compareUnityVersionString(project.unity, "2018.0.0f0") < 0) {
|
||||
// No UPM is supported in unity 2017 or older
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfValid asChild>
|
||||
<ButtonDisabledIfInvalid variant="success" disabled>
|
||||
{tc("projects:button:manage")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
</TooltipTriggerIfValid>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:no upm in unity")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
switch (project.project_type) {
|
||||
case "LegacySdk2":
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfValid asChild>
|
||||
<ButtonDisabledIfInvalid variant="success" disabled>
|
||||
{tc("projects:button:migrate")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
</TooltipTriggerIfValid>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:sdk2 migration hint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "LegacyWorlds":
|
||||
case "LegacyAvatars":
|
||||
return (
|
||||
<ButtonDisabledIfInvalid
|
||||
variant={"success"}
|
||||
onClick={() => void migrateVpm(project.path)}
|
||||
>
|
||||
{tc("projects:button:migrate")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
);
|
||||
case "UpmWorlds":
|
||||
case "UpmAvatars":
|
||||
case "UpmStarter":
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTriggerIfValid asChild>
|
||||
<ButtonDisabledIfInvalid variant="info" disabled>
|
||||
{tc("projects:button:manage")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
</TooltipTriggerIfValid>
|
||||
<TooltipContent>
|
||||
{tc("projects:tooltip:git-vcc not supported")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "Unknown":
|
||||
case "Worlds":
|
||||
case "Avatars":
|
||||
case "VpmStarter":
|
||||
return (
|
||||
<ButtonDisabledIfInvalid
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/projects/manage",
|
||||
search: { projectPath: project.path },
|
||||
})
|
||||
}
|
||||
variant="info"
|
||||
>
|
||||
{tc("projects:button:manage")}
|
||||
</ButtonDisabledIfInvalid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type MigrationProjectBackupType = "none" | "copy" | "backupArchive";
|
||||
|
||||
async function migrateVpm(projectPath: string) {
|
||||
if (await commands.projectIsUnityLaunching(projectPath)) {
|
||||
toastError(tt("projects:toast:close unity before migration"));
|
||||
return;
|
||||
}
|
||||
|
||||
using dialog = showDialog();
|
||||
|
||||
const backupType = await dialog.ask(ConfirmVpmMigrationDialog, {});
|
||||
if (backupType == null) return "";
|
||||
|
||||
let migrateProjectPath: string;
|
||||
switch (backupType) {
|
||||
case "none":
|
||||
migrateProjectPath = projectPath;
|
||||
break;
|
||||
case "copy": {
|
||||
migrateProjectPath = await dialog.ask(MigrationCopyingDialog, {
|
||||
header: tc("projects:dialog:vpm migrate header"),
|
||||
projectPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "backupArchive": {
|
||||
const result = await dialog.ask(BackupProjectDialog, {
|
||||
projectPath,
|
||||
});
|
||||
if (result === "cancelled") {
|
||||
return;
|
||||
}
|
||||
migrateProjectPath = projectPath;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(backupType);
|
||||
}
|
||||
dialog.replace(<VpmMigrationUpdating />);
|
||||
await commands.projectMigrateProjectToVpm(migrateProjectPath);
|
||||
toastSuccess(tt("projects:toast:project migrated"));
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentProjects"],
|
||||
});
|
||||
|
||||
router.navigate({
|
||||
to: "/projects/manage",
|
||||
search: {
|
||||
projectPath: migrateProjectPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ConfirmVpmMigrationDialog({
|
||||
dialog,
|
||||
}: {
|
||||
dialog: DialogContext<MigrationProjectBackupType | null>;
|
||||
}) {
|
||||
return (
|
||||
<div className={"contents whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:dialog:vpm migrate description")}</p>
|
||||
</div>
|
||||
<DialogFooter className={"gap-1"}>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("backupArchive")}>
|
||||
{tc("projects:button:backup and migrate")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("copy")}>
|
||||
{tc("projects:button:migrate copy")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VpmMigrationUpdating() {
|
||||
return (
|
||||
<div className={"contents whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:migrating...")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// region utilities
|
||||
|
||||
export const ProjectContext = React.createContext<{
|
||||
removed: boolean;
|
||||
is_valid: boolean | null;
|
||||
loading: boolean;
|
||||
}>({
|
||||
removed: false,
|
||||
is_valid: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
export const ButtonDisabledIfInvalid = function RemovedButton(
|
||||
props: React.ComponentProps<typeof Button>,
|
||||
) {
|
||||
const rowContext = useContext(ProjectContext);
|
||||
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
{...props}
|
||||
className={`disabled:pointer-events-auto ${props.className}`}
|
||||
disabled
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{rowContext.removed
|
||||
? tc("projects:tooltip:no directory")
|
||||
: tc("projects:tooltip:invalid project")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
className={`disabled:pointer-events-auto ${props.className}`}
|
||||
disabled={props.disabled || rowContext.loading || rowContext.removed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const TooltipTriggerIfInvalid = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof TooltipTrigger>) => {
|
||||
const rowContext = useContext(ProjectContext);
|
||||
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
|
||||
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
|
||||
} else {
|
||||
return children;
|
||||
}
|
||||
};
|
||||
|
||||
export const TooltipTriggerIfValid = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof TooltipTrigger>) => {
|
||||
const rowContext = useContext(ProjectContext);
|
||||
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
|
||||
return children;
|
||||
} else {
|
||||
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
|
||||
}
|
||||
};
|
||||
|
||||
export function getProjectDisplayInfo(project: TauriProject) {
|
||||
const projectTypeKind = ProjectDisplayType[project.project_type] ?? "unknown";
|
||||
const displayType = tc(`projects:type:${projectTypeKind}`);
|
||||
const isLegacy = LegacyProjectTypes.includes(project.project_type);
|
||||
const createdAt = new Date(project.created_at);
|
||||
const lastModified = new Date(project.last_modified);
|
||||
|
||||
return {
|
||||
projectTypeKind,
|
||||
displayType,
|
||||
isLegacy,
|
||||
createdAt,
|
||||
lastModified,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSetProjectFavoriteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (project: Pick<TauriProject, "path" | "favorite">) =>
|
||||
commands.environmentSetFavoriteProject(project.path, project.favorite),
|
||||
|
||||
onMutate: async (project) => {
|
||||
await queryClient.cancelQueries(environmentProjects);
|
||||
|
||||
const previousData = queryClient.getQueryData<TauriProject[]>(
|
||||
environmentProjects.queryKey,
|
||||
);
|
||||
|
||||
if (previousData !== undefined) {
|
||||
queryClient.setQueryData<TauriProject[]>(
|
||||
environmentProjects.queryKey,
|
||||
previousData.map((v) =>
|
||||
v.path === project.path ? { ...v, favorite: project.favorite } : v,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return previousData;
|
||||
},
|
||||
|
||||
onError: (error, _, context) => {
|
||||
console.error("Error migrating project", error);
|
||||
toastThrownError(error);
|
||||
if (context) {
|
||||
queryClient.setQueryData(environmentProjects.queryKey, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// endregion
|
||||
131
vrc-get-gui/app/_main/projects/-projects-grid-card.tsx
Normal file
131
vrc-get-gui/app/_main/projects/-projects-grid-card.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { TauriProject } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { ProjectGridItem } from "./-project-grid-item";
|
||||
import {
|
||||
isSorting,
|
||||
type sortings,
|
||||
sortSearchProjects,
|
||||
useSetProjectSortingMutation,
|
||||
} from "./-projects-list-card";
|
||||
|
||||
type SimpleSorting = (typeof sortings)[number];
|
||||
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
|
||||
|
||||
const sortingOptions: { key: SimpleSorting; label: string }[] = [
|
||||
{ key: "name", label: "general:name" },
|
||||
{ key: "type", label: "projects:type" },
|
||||
{ key: "unity", label: "projects:unity" },
|
||||
{ key: "createdAt", label: "general:created at" },
|
||||
{ key: "lastModified", label: "general:last modified" },
|
||||
];
|
||||
|
||||
export function ProjectsGridCard({
|
||||
projects,
|
||||
search,
|
||||
loading,
|
||||
}: {
|
||||
projects: TauriProject[];
|
||||
search?: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const sortingQuery = useQuery({
|
||||
initialData: "lastModified" as Sorting,
|
||||
queryKey: ["environmentGetProjectSorting"],
|
||||
queryFn: async () => {
|
||||
const newSorting = await commands.environmentGetProjectSorting();
|
||||
return !isSorting(newSorting) ? "lastModified" : newSorting;
|
||||
},
|
||||
});
|
||||
|
||||
const setSortingStateMutation = useSetProjectSortingMutation();
|
||||
|
||||
const currentKey = sortingQuery.data.replace(
|
||||
/Reversed$/,
|
||||
"",
|
||||
) as SimpleSorting;
|
||||
const isReversed = sortingQuery.data.endsWith("Reversed");
|
||||
|
||||
const handleChangeSortingKey = (key: SimpleSorting) => {
|
||||
const newSorting = isReversed ? `${key}Reversed` : key;
|
||||
setSortingStateMutation.mutate({ sorting: newSorting as Sorting });
|
||||
};
|
||||
|
||||
const toggleOrder = () => {
|
||||
const newSorting: Sorting = isReversed
|
||||
? currentKey
|
||||
: `${currentKey}Reversed`;
|
||||
setSortingStateMutation.mutate({ sorting: newSorting });
|
||||
};
|
||||
|
||||
const projectsShown = useMemo(() => {
|
||||
return sortSearchProjects(projects, search ?? "", sortingQuery.data);
|
||||
}, [projects, search, sortingQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||
<Card className="flex items-center mb-3 flex-wrap p-2 gap-2 compact:p-1 compact:gap-1">
|
||||
<p className="grow-0 whitespace-pre pl-2 leading-tight">
|
||||
{tc("projects:sort by")}
|
||||
</p>
|
||||
<Select
|
||||
value={currentKey}
|
||||
onValueChange={(value) =>
|
||||
handleChangeSortingKey(value as SimpleSorting)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortingOptions.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
{tc(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleOrder}>
|
||||
{isReversed ? (
|
||||
<ArrowUp className="size-4" />
|
||||
) : (
|
||||
<ArrowDown className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Card>
|
||||
<ScrollArea
|
||||
type="auto"
|
||||
className="h-full w-full vrc-get-scrollable-card rounded-l-xl"
|
||||
scrollBarClassName="bg-background rounded-full border-l-0 p-[1.5px]"
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3 overflow-x-hidden mr-4
|
||||
compact:grid-cols-2 compact:lg:grid-cols-3 compact:2xl:grid-cols-4 compact:gap-1.5"
|
||||
>
|
||||
{projectsShown.map((project) => (
|
||||
<ProjectGridItem
|
||||
key={project.path}
|
||||
project={project}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
280
vrc-get-gui/app/_main/projects/-projects-list-card.tsx
Normal file
280
vrc-get-gui/app/_main/projects/-projects-list-card.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"use client";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Star } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { compareUnityVersionString } from "@/lib/version";
|
||||
import { ProjectRow } from "./-project-row";
|
||||
|
||||
export const sortings = [
|
||||
"createdAt",
|
||||
"lastModified",
|
||||
"name",
|
||||
"unity",
|
||||
"type",
|
||||
] as const;
|
||||
|
||||
type SimpleSorting = (typeof sortings)[number];
|
||||
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
|
||||
|
||||
export function isSorting(s: string | unknown): s is Sorting {
|
||||
return sortings.some(
|
||||
(sorting) => sorting === s || `${sorting}Reversed` === s,
|
||||
);
|
||||
}
|
||||
|
||||
export function compareProjectType(
|
||||
a: TauriProjectType,
|
||||
b: TauriProjectType,
|
||||
): 0 | -1 | 1 {
|
||||
if (a === b) return 0;
|
||||
|
||||
// legacy unknown
|
||||
if (a === "LegacySdk2") return 1;
|
||||
if (b === "LegacySdk2") return -1;
|
||||
if (a === "UpmStarter") return 1;
|
||||
if (b === "UpmStarter") return -1;
|
||||
|
||||
// legacy worlds
|
||||
if (a === "LegacyWorlds") return 1;
|
||||
if (b === "LegacyWorlds") return -1;
|
||||
if (a === "UpmWorlds") return 1;
|
||||
if (b === "UpmWorlds") return -1;
|
||||
|
||||
// legacy avatars
|
||||
if (a === "LegacyAvatars") return 1;
|
||||
if (b === "LegacyAvatars") return -1;
|
||||
if (a === "UpmAvatars") return 1;
|
||||
if (b === "UpmAvatars") return -1;
|
||||
|
||||
// unknown
|
||||
if (a === "Unknown") return 1;
|
||||
if (b === "Unknown") return -1;
|
||||
if (a === "VpmStarter") return 1;
|
||||
if (b === "VpmStarter") return -1;
|
||||
|
||||
// worlds
|
||||
if (a === "Worlds") return 1;
|
||||
if (b === "Worlds") return -1;
|
||||
|
||||
// avatars
|
||||
if (a === "Avatars") return 1;
|
||||
if (b === "Avatars") return -1;
|
||||
|
||||
assertNever(a, "project type");
|
||||
}
|
||||
|
||||
export function ProjectsTableCard({
|
||||
projects,
|
||||
search,
|
||||
loading,
|
||||
}: {
|
||||
projects: TauriProject[];
|
||||
search?: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const sortingQuery = useQuery({
|
||||
initialData: "lastModified" as Sorting,
|
||||
queryKey: ["environmentGetProjectSorting"],
|
||||
queryFn: async () => {
|
||||
const newSorting = await commands.environmentGetProjectSorting();
|
||||
return !isSorting(newSorting) ? "lastModified" : newSorting;
|
||||
},
|
||||
});
|
||||
|
||||
const setSortingStateMutation = useSetProjectSortingMutation();
|
||||
|
||||
const projectsShown = useMemo(() => {
|
||||
return sortSearchProjects(projects, search ?? "", sortingQuery.data);
|
||||
}, [projects, search, sortingQuery.data]);
|
||||
|
||||
const thClass = "sticky top-0 z-10 border-b border-primary p-2.5";
|
||||
const iconClass = "size-3 invisible project-table-header-chevron-up-down";
|
||||
|
||||
const setSorting = async (simpleSorting: SimpleSorting) => {
|
||||
let newSorting: Sorting;
|
||||
if (sortingQuery.data === simpleSorting) {
|
||||
newSorting = `${simpleSorting}Reversed`;
|
||||
} else if (sortingQuery.data === `${simpleSorting}Reversed`) {
|
||||
newSorting = simpleSorting;
|
||||
} else {
|
||||
newSorting = simpleSorting;
|
||||
}
|
||||
setSortingStateMutation.mutate({ sorting: newSorting });
|
||||
};
|
||||
|
||||
const headerBg = (target: SimpleSorting) =>
|
||||
sortingQuery.data === target || sortingQuery.data === `${target}Reversed`
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground";
|
||||
const icon = (target: SimpleSorting) =>
|
||||
sortingQuery.data === target ? (
|
||||
<ChevronDown className={"size-3"} />
|
||||
) : sortingQuery.data === `${target}Reversed` ? (
|
||||
<ChevronUp className={"size-3"} />
|
||||
) : (
|
||||
<ChevronsUpDown className={iconClass} />
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollableCardTable className={"h-full w-full"}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${thClass} bg-secondary text-secondary-foreground`}>
|
||||
<Star className={"size-4"} />
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("name")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("name")}
|
||||
>
|
||||
{icon("name")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("general:name")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("type")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("type")}
|
||||
>
|
||||
{icon("type")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("projects:type")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("unity")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("unity")}
|
||||
>
|
||||
{icon("unity")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("projects:unity")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("createdAt")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("createdAt")}
|
||||
>
|
||||
{icon("createdAt")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("general:created at")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} ${headerBg("lastModified")}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={"flex w-full project-table-button"}
|
||||
onClick={() => setSorting("lastModified")}
|
||||
>
|
||||
{icon("lastModified")}
|
||||
<small className="font-normal leading-none">
|
||||
{tc("general:last modified")}
|
||||
</small>
|
||||
</button>
|
||||
</th>
|
||||
<th className={`${thClass} bg-secondary text-secondary-foreground`} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectsShown.map((project) => (
|
||||
<ProjectRow key={project.path} project={project} loading={loading} />
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
);
|
||||
}
|
||||
|
||||
export function sortSearchProjects(
|
||||
projects: TauriProject[],
|
||||
search: string,
|
||||
sorting: Sorting,
|
||||
): TauriProject[] {
|
||||
const searched = projects.filter((project) =>
|
||||
project.name.toLowerCase().includes(search?.toLowerCase() ?? ""),
|
||||
);
|
||||
|
||||
searched.sort((a, b) => b.last_modified - a.last_modified);
|
||||
|
||||
switch (sorting) {
|
||||
case "createdAt":
|
||||
searched.sort((a, b) => b.created_at - a.created_at);
|
||||
break;
|
||||
case "createdAtReversed":
|
||||
searched.sort((a, b) => a.created_at - b.created_at);
|
||||
break;
|
||||
case "lastModified":
|
||||
searched.sort((a, b) => b.last_modified - a.last_modified);
|
||||
break;
|
||||
case "lastModifiedReversed":
|
||||
searched.sort((a, b) => a.last_modified - b.last_modified);
|
||||
break;
|
||||
case "name":
|
||||
searched.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "nameReversed":
|
||||
searched.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "type":
|
||||
searched.sort((a, b) =>
|
||||
compareProjectType(a.project_type, b.project_type),
|
||||
);
|
||||
break;
|
||||
case "typeReversed":
|
||||
searched.sort((a, b) =>
|
||||
compareProjectType(b.project_type, a.project_type),
|
||||
);
|
||||
break;
|
||||
case "unity":
|
||||
searched.sort((a, b) => compareUnityVersionString(a.unity, b.unity));
|
||||
break;
|
||||
case "unityReversed":
|
||||
searched.sort((a, b) => compareUnityVersionString(b.unity, a.unity));
|
||||
break;
|
||||
default:
|
||||
assertNever(sorting);
|
||||
}
|
||||
|
||||
searched.sort((a, b) => {
|
||||
if (a.favorite && !b.favorite) return -1;
|
||||
if (!a.favorite && b.favorite) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return searched;
|
||||
}
|
||||
|
||||
export function useSetProjectSortingMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sorting }: { sorting: Sorting }) => {
|
||||
await commands.environmentSetProjectSorting(sorting);
|
||||
},
|
||||
onMutate: async ({ sorting }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["environmentGetProjectSorting"],
|
||||
});
|
||||
queryClient.setQueryData(["environmentGetProjectSorting"], () => sorting);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error setting project sorting", error);
|
||||
toastThrownError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
280
vrc-get-gui/app/_main/projects/index.tsx
Normal file
280
vrc-get-gui/app/_main/projects/index.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ChevronDown, LayoutGrid, LayoutList, RefreshCw } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { createProject } from "@/app/_main/projects/-create-project";
|
||||
import { ProjectsGridCard } from "@/app/_main/projects/-projects-grid-card";
|
||||
import Loading from "@/app/-loading";
|
||||
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
|
||||
import { SearchBox } from "@/components/SearchBox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { isFindKey, useDocumentEvent } from "@/lib/events";
|
||||
import { useProjectUpdateInProgress } from "@/lib/global-events";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { ProjectsTableCard } from "./-projects-list-card";
|
||||
|
||||
export const Route = createFileRoute("/_main/projects/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
const environmentProjects = queryOptions({
|
||||
queryKey: ["environmentProjects"],
|
||||
queryFn: commands.environmentProjects,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
const result = useQuery(environmentProjects);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const viewModeQuery = useQuery({
|
||||
initialData: "List",
|
||||
queryKey: ["environmentGetProjectViewMode"],
|
||||
queryFn: async () => {
|
||||
return await commands.environmentProjectViewMode();
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setViewModeMutation = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
await commands.environmentSetProjectViewMode(value);
|
||||
},
|
||||
onMutate: async (value: string) => {
|
||||
await queryClient.setQueryData(["environmentGetProjectViewMode"], value);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentGetProjectViewMode"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const viewMode = viewModeQuery.data ?? true;
|
||||
|
||||
const setViewMode = (value: string) => {
|
||||
setViewModeMutation.mutate(value);
|
||||
};
|
||||
|
||||
const startCreateProject = () => void createProject();
|
||||
|
||||
const loading = result.isFetching;
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<ProjectViewHeader
|
||||
startCreateProject={startCreateProject}
|
||||
isLoading={loading}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
/>
|
||||
<main className="shrink overflow-hidden flex w-full h-full">
|
||||
{result.status === "pending" ? (
|
||||
<Card className="w-full shadow-none overflow-hidden p-4">
|
||||
<Loading loadingText={tc("general:loading...")} />
|
||||
</Card>
|
||||
) : result.status === "error" ? (
|
||||
<Card className="w-full shadow-none overflow-hidden p-4">
|
||||
{tc("projects:error:load error", { msg: result.error.message })}
|
||||
</Card>
|
||||
) : viewMode === "List" ? (
|
||||
<ProjectsTableCard
|
||||
projects={result.data}
|
||||
search={search}
|
||||
loading={loading}
|
||||
/>
|
||||
) : viewMode === "Grid" ? (
|
||||
<ProjectsGridCard
|
||||
projects={result.data}
|
||||
search={search}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<ProjectsTableCard
|
||||
projects={result.data}
|
||||
search={search}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectViewHeader({
|
||||
startCreateProject,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
}: {
|
||||
startCreateProject?: () => void;
|
||||
isLoading?: boolean;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
viewMode: string;
|
||||
setViewMode: (viewMode: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const addProjectWithPicker = useMutation({
|
||||
mutationFn: async () => await commands.environmentAddProjectWithPicker(),
|
||||
onSuccess: (result) => {
|
||||
switch (result) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tt("projects:toast:project added"));
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastError(tt("projects:toast:project already exists"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("Error adding project", e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentProjects);
|
||||
},
|
||||
});
|
||||
|
||||
const inProgress = useProjectUpdateInProgress();
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useDocumentEvent(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (isFindKey(e)) {
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
isLoading = isLoading || inProgress;
|
||||
|
||||
return (
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={
|
||||
<>
|
||||
<HNavBarText>{tc("projects")}</HNavBarText>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={"compact:h-10 compact:w-10"}
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries(environmentProjects)
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className={"w-5 h-5"} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tc("projects:tooltip:refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<SearchBox
|
||||
className={"w-max grow compact:h-10"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
ref={searchRef}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={"compact:h-10"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
if (viewMode === "List") {
|
||||
setViewMode("Grid");
|
||||
} else {
|
||||
setViewMode("List");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewMode === "List" ? (
|
||||
<>
|
||||
<LayoutList className={"w-5 h-5"} />
|
||||
<p className="ml-2">{tc("projects:list view")}</p>
|
||||
</>
|
||||
) : viewMode === "Grid" ? (
|
||||
<>
|
||||
<LayoutGrid className={"w-5 h-5"} />
|
||||
<p className="ml-2">{tc("projects:grid view")}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayoutList className={"w-5 h-5"} />
|
||||
<p className="ml-2">{tc("projects:list view")}</p>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<Button
|
||||
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
|
||||
onClick={startCreateProject}
|
||||
>
|
||||
{tc("projects:create new project")}
|
||||
</Button>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
|
||||
>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => addProjectWithPicker.mutate()}>
|
||||
{tc("projects:add existing project")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TauriUserRepository,
|
||||
TauriVersion,
|
||||
} from "@/lib/bindings";
|
||||
import { VRCSDK_PACKAGES } from "@/lib/constants";
|
||||
import {
|
||||
compareUnityVersion,
|
||||
compareVersion,
|
||||
|
|
@ -27,30 +28,33 @@ export type PackageLatestInfo =
|
|||
hasUnityIncompatibleLatest: boolean;
|
||||
};
|
||||
|
||||
type UrlInfo = {
|
||||
// null source means URL comes from installed one which has the highest priority
|
||||
url: string;
|
||||
source: TauriVersion | null;
|
||||
};
|
||||
|
||||
export interface PackageRowInfo {
|
||||
id: string;
|
||||
infoSource: TauriVersion;
|
||||
displayName: string;
|
||||
description: string;
|
||||
aliases: string[];
|
||||
keywords: string[];
|
||||
unityCompatible: Map<string, TauriPackage>;
|
||||
unityIncompatible: Map<string, TauriPackage>;
|
||||
sources: Set<string>;
|
||||
isThereSource: boolean; // this will be true even if all sources are hidden
|
||||
visibleSources: Set<string>;
|
||||
installed: null | {
|
||||
version: TauriVersion;
|
||||
yanked: boolean;
|
||||
};
|
||||
latest: PackageLatestInfo;
|
||||
stableLatest: PackageLatestInfo;
|
||||
changelogUrl: null | UrlInfo;
|
||||
documentationUrl: null | UrlInfo;
|
||||
}
|
||||
|
||||
export const VRCSDK_PACKAGES = [
|
||||
"com.vrchat.avatars",
|
||||
"com.vrchat.worlds",
|
||||
"com.vrchat.base",
|
||||
];
|
||||
|
||||
export function combinePackagesAndProjectDetails(
|
||||
packages: TauriPackage[],
|
||||
project: TauriProjectDetails | null,
|
||||
|
|
@ -89,7 +93,9 @@ export function combinePackagesAndProjectDetails(
|
|||
const yankedVersions = new Set<`${string}:${string}`>();
|
||||
const knownPackages = new Set<string>();
|
||||
const packagesPerRepository = new Map<string, TauriPackage[]>();
|
||||
const hiddenPackagesPerRepository = new Map<string, TauriPackage[]>();
|
||||
const userPackages: TauriPackage[] = [];
|
||||
const hiddenUserPackages: TauriPackage[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!showPrereleasePackages && pkg.version.pre) continue;
|
||||
|
|
@ -104,13 +110,19 @@ export function combinePackagesAndProjectDetails(
|
|||
let packages: TauriPackage[];
|
||||
// check the repository is visible
|
||||
if (pkg.source === "LocalUser") {
|
||||
if (hideLocalUserPackages) continue;
|
||||
packages = userPackages;
|
||||
if (hideLocalUserPackages) {
|
||||
packages = hiddenUserPackages;
|
||||
} else {
|
||||
packages = userPackages;
|
||||
}
|
||||
} else if ("Remote" in pkg.source) {
|
||||
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) continue;
|
||||
|
||||
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
|
||||
packagesPerRepository.set(pkg.source.Remote.id, packages);
|
||||
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
|
||||
packages = hiddenPackagesPerRepository.get(pkg.source.Remote.id) ?? [];
|
||||
hiddenPackagesPerRepository.set(pkg.source.Remote.id, packages);
|
||||
} else {
|
||||
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
|
||||
packagesPerRepository.set(pkg.source.Remote.id, packages);
|
||||
}
|
||||
} else {
|
||||
assertNever(pkg.source);
|
||||
}
|
||||
|
|
@ -129,15 +141,18 @@ export function combinePackagesAndProjectDetails(
|
|||
id: pkg.name,
|
||||
displayName: pkg.display_name ?? pkg.name,
|
||||
description: pkg.description ?? "",
|
||||
aliases: pkg.aliases,
|
||||
keywords: pkg.keywords,
|
||||
infoSource: pkg.version,
|
||||
unityCompatible: new Map(),
|
||||
unityIncompatible: new Map(),
|
||||
sources: new Set(),
|
||||
isThereSource: false,
|
||||
visibleSources: new Set(),
|
||||
installed: null,
|
||||
latest: { status: "none" },
|
||||
stableLatest: { status: "none" },
|
||||
changelogUrl: null,
|
||||
documentationUrl: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -148,13 +163,21 @@ export function combinePackagesAndProjectDetails(
|
|||
const packageRowInfo = getRowInfo(pkg);
|
||||
packageRowInfo.isThereSource = true;
|
||||
|
||||
setUrlInfo(packageRowInfo, "changelogUrl", pkg.changelog_url, pkg.version);
|
||||
setUrlInfo(
|
||||
packageRowInfo,
|
||||
"documentationUrl",
|
||||
pkg.documentation_url,
|
||||
pkg.version,
|
||||
);
|
||||
|
||||
if (compareVersion(pkg.version, packageRowInfo.infoSource) > 0) {
|
||||
// use display name from the latest version
|
||||
packageRowInfo.infoSource = pkg.version;
|
||||
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
|
||||
packageRowInfo.description =
|
||||
pkg.description || packageRowInfo.description;
|
||||
packageRowInfo.aliases = pkg.aliases;
|
||||
packageRowInfo.keywords = pkg.keywords;
|
||||
}
|
||||
|
||||
if (project == null || isUnityCompatible(pkg, project.unity)) {
|
||||
|
|
@ -165,8 +188,14 @@ export function combinePackagesAndProjectDetails(
|
|||
|
||||
if (pkg.source === "LocalUser") {
|
||||
packageRowInfo.sources.add("User");
|
||||
if (!hideLocalUserPackages) {
|
||||
packageRowInfo.visibleSources.add("User");
|
||||
}
|
||||
} else if ("Remote" in pkg.source) {
|
||||
packageRowInfo.sources.add(pkg.source.Remote.display_name);
|
||||
if (!hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
|
||||
packageRowInfo.visibleSources.add(pkg.source.Remote.display_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +203,13 @@ export function combinePackagesAndProjectDetails(
|
|||
packagesPerRepository.get("com.vrchat.repos.official")?.forEach(addPackage);
|
||||
packagesPerRepository.get("com.vrchat.repos.curated")?.forEach(addPackage);
|
||||
userPackages.forEach(addPackage);
|
||||
hiddenUserPackages.forEach((pkg) => {
|
||||
const packageRowInfo = getRowInfo(pkg);
|
||||
packageRowInfo.isThereSource = true;
|
||||
if (pkg.source === "LocalUser") {
|
||||
packageRowInfo.sources.add("User");
|
||||
}
|
||||
});
|
||||
packagesPerRepository.delete("com.vrchat.repos.official");
|
||||
packagesPerRepository.delete("com.vrchat.repos.curated");
|
||||
|
||||
|
|
@ -188,6 +224,17 @@ export function combinePackagesAndProjectDetails(
|
|||
packages.forEach(addPackage);
|
||||
}
|
||||
|
||||
// process hidden repositories - only add to sources, not to version calculations
|
||||
for (const packages of hiddenPackagesPerRepository.values()) {
|
||||
packages.forEach((pkg) => {
|
||||
const packageRowInfo = getRowInfo(pkg);
|
||||
packageRowInfo.isThereSource = true;
|
||||
if (pkg.source !== "LocalUser") {
|
||||
packageRowInfo.sources.add(pkg.source.Remote.display_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// sort versions
|
||||
for (const value of packagesTable.values()) {
|
||||
value.unityCompatible = new Map(
|
||||
|
|
@ -269,16 +316,24 @@ export function combinePackagesAndProjectDetails(
|
|||
for (const [_, pkg] of project.installed_packages) {
|
||||
const packageRowInfo = getRowInfo(pkg);
|
||||
|
||||
setUrlInfo(packageRowInfo, "changelogUrl", pkg.changelog_url, null);
|
||||
setUrlInfo(
|
||||
packageRowInfo,
|
||||
"documentationUrl",
|
||||
pkg.documentation_url,
|
||||
null,
|
||||
);
|
||||
|
||||
// if installed, use the installed version to get the display name
|
||||
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
|
||||
packageRowInfo.aliases = [...pkg.aliases, ...packageRowInfo.aliases];
|
||||
packageRowInfo.keywords = [...pkg.keywords, ...packageRowInfo.keywords];
|
||||
packageRowInfo.installed = {
|
||||
version: pkg.version,
|
||||
yanked:
|
||||
pkg.is_yanked ||
|
||||
yankedVersions.has(`${pkg.name}:${toVersionString(pkg.version)}`),
|
||||
};
|
||||
packageRowInfo.isThereSource = knownPackages.has(pkg.name);
|
||||
packageRowInfo.isThereSource = true;
|
||||
|
||||
// if we have the latest version, check if it's upgradable
|
||||
if (packageRowInfo.latest.status !== "none") {
|
||||
|
|
@ -375,3 +430,25 @@ export function combinePackagesAndProjectDetails(
|
|||
|
||||
return asArray;
|
||||
}
|
||||
|
||||
function setUrlInfo<K extends string>(
|
||||
obj: { [P in K]: null | UrlInfo },
|
||||
key: K,
|
||||
url: string | null,
|
||||
version: TauriVersion | null,
|
||||
) {
|
||||
if (url == null) return;
|
||||
const current = obj[key];
|
||||
if (current == null) {
|
||||
obj[key] = { url, source: version };
|
||||
} else {
|
||||
if (version == null) {
|
||||
obj[key] = { url, source: version };
|
||||
} else if (current.source == null) {
|
||||
// do not update
|
||||
} else if (compareVersion(current.source, version) < 0) {
|
||||
// if this version is newer than current, update
|
||||
obj[key] = { url, source: version };
|
||||
}
|
||||
}
|
||||
}
|
||||
214
vrc-get-gui/app/_main/projects/manage/-copy-project.tsx
Normal file
214
vrc-get-gui/app/_main/projects/manage/-copy-project.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { NavigateFn } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { VStack } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import { commands, type TauriCopyProjectProgress } from "@/lib/bindings";
|
||||
import { callAsyncCommand } from "@/lib/call-async-command";
|
||||
import { type DialogContext, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { directoryFromPath, nameFromPath, pathSeparator } from "@/lib/os";
|
||||
import {
|
||||
ProjectNameCheckResult,
|
||||
useProjectNameCheck,
|
||||
} from "@/lib/project-name-check";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
|
||||
export async function copyProject(existingPath: string, navigate?: NavigateFn) {
|
||||
using dialog = showDialog();
|
||||
const newPath = await dialog.ask(CopyProjectNameDialog, {
|
||||
projectPath: existingPath,
|
||||
});
|
||||
if (newPath == null) return; // cancelled
|
||||
await dialog.ask(CopyingDialog, {
|
||||
projectPath: existingPath,
|
||||
newProjectPath: newPath,
|
||||
});
|
||||
dialog.close();
|
||||
toastSuccess(
|
||||
tc("projects:toast:successfully copied project", {
|
||||
name: nameFromPath(existingPath),
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projectDetails", existingPath],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["environmentProjects"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await navigate?.({
|
||||
replace: true,
|
||||
to: "/projects/manage",
|
||||
search: { projectPath: newPath },
|
||||
});
|
||||
}
|
||||
|
||||
function CopyProjectNameDialog({
|
||||
dialog,
|
||||
projectPath,
|
||||
}: {
|
||||
dialog: DialogContext<string | null>;
|
||||
projectPath: string;
|
||||
}) {
|
||||
const oldName = nameFromPath(projectPath);
|
||||
const [projectNameRaw, setProjectName] = useState(`${oldName}-Copy`);
|
||||
const projectName = projectNameRaw.trim();
|
||||
const [projectLocation, setProjectLocation] = useState(
|
||||
directoryFromPath(projectPath),
|
||||
);
|
||||
const projectNameCheckState = useProjectNameCheck(
|
||||
projectLocation,
|
||||
projectName,
|
||||
);
|
||||
|
||||
const usePickProjectLocationPath = useMutation({
|
||||
mutationFn: () => commands.utilPickDirectory(projectLocation),
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
setProjectLocation(result.new_path);
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const createProject = async () => {
|
||||
dialog.close(`${projectLocation}${pathSeparator()}${projectName}`);
|
||||
};
|
||||
|
||||
const badProjectName = ["AlreadyExists", "InvalidNameForFolderName"].includes(
|
||||
projectNameCheckState,
|
||||
);
|
||||
|
||||
const canCreateProject =
|
||||
projectNameCheckState !== "checking" && !badProjectName;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{tc("projects:dialog:copy project", { name: oldName })}
|
||||
</DialogTitle>
|
||||
<div>
|
||||
<VStack>
|
||||
<Input
|
||||
value={projectNameRaw}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
<div className={"flex gap-1 items-center"}>
|
||||
<Input className="flex-auto" value={projectLocation} disabled />
|
||||
<Button
|
||||
className="flex-none px-4"
|
||||
onClick={() => usePickProjectLocationPath.mutate()}
|
||||
>
|
||||
{tc("general:button:select")}
|
||||
</Button>
|
||||
</div>
|
||||
<small className={"whitespace-normal"}>
|
||||
{tc(
|
||||
"projects:hint:path of creating project",
|
||||
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
|
||||
{
|
||||
components: {
|
||||
path: (
|
||||
<span
|
||||
className={
|
||||
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
<ProjectNameCheckResult
|
||||
projectNameCheckState={projectNameCheckState}
|
||||
/>
|
||||
</VStack>
|
||||
</div>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={createProject} disabled={!canCreateProject}>
|
||||
{tc("projects:button:create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyingDialog({
|
||||
projectPath,
|
||||
newProjectPath,
|
||||
dialog,
|
||||
}: {
|
||||
projectPath: string;
|
||||
newProjectPath: string;
|
||||
dialog: DialogContext<string>;
|
||||
}) {
|
||||
const oldName = nameFromPath(projectPath);
|
||||
|
||||
const [progress, setProgress] = useState<TauriCopyProjectProgress>({
|
||||
proceed: 0,
|
||||
total: 1,
|
||||
last_proceed: "Collecting files...",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const [_, promise] = callAsyncCommand(
|
||||
commands.environmentCopyProject,
|
||||
[projectPath, newProjectPath],
|
||||
(progress) => {
|
||||
setProgress((prev) => {
|
||||
if (prev.proceed > progress.proceed) return prev;
|
||||
return progress;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
promise.then(dialog.close, dialog.error);
|
||||
}, [projectPath, newProjectPath, dialog.close, dialog.error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{tc("projects:dialog:copy project", { name: oldName })}
|
||||
</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:dialog:copying...")}</p>
|
||||
<p>
|
||||
{tc("projects:dialog:proceed k/n", {
|
||||
count: progress.proceed,
|
||||
total: progress.total,
|
||||
})}
|
||||
</p>
|
||||
<Progress value={progress.proceed} max={progress.total} />
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
</div>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
<Button disabled>{tc("general:button:cancel")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1258
vrc-get-gui/app/_main/projects/manage/-package-list-card.tsx
Normal file
1258
vrc-get-gui/app/_main/projects/manage/-package-list-card.tsx
Normal file
File diff suppressed because it is too large
Load diff
44
vrc-get-gui/app/_main/projects/manage/-page-context.tsx
Normal file
44
vrc-get-gui/app/_main/projects/manage/-page-context.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface PageContext {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const PageContext = createContext<PageContext>({
|
||||
isLoading: false,
|
||||
});
|
||||
PageContext.displayName = "PageContext";
|
||||
|
||||
export const PageContextProvider = PageContext.Provider;
|
||||
|
||||
export function usePageContext() {
|
||||
return useContext(PageContext);
|
||||
}
|
||||
|
||||
export const ButtonDisabledIfLoading = function ButtonDisabledIfLoading({
|
||||
disabled,
|
||||
...props
|
||||
}: ComponentProps<typeof Button>) {
|
||||
const { isLoading } = usePageContext();
|
||||
return <Button disabled={isLoading || disabled} {...props} />;
|
||||
};
|
||||
|
||||
export const DropdownMenuItemDisabledIfLoading =
|
||||
function ButtonDisabledIfLoading({
|
||||
disabled,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuItem>) {
|
||||
const { isLoading } = usePageContext();
|
||||
return <DropdownMenuItem disabled={isLoading || disabled} {...props} />;
|
||||
};
|
||||
|
||||
export const CheckboxDisabledIfLoading = function CheckboxDisabledIfLoading({
|
||||
disabled,
|
||||
...props
|
||||
}: ComponentProps<typeof Checkbox>) {
|
||||
const { isLoading } = usePageContext();
|
||||
return <Checkbox disabled={isLoading || disabled} {...props} />;
|
||||
};
|
||||
583
vrc-get-gui/app/_main/projects/manage/-unity-migration.tsx
Normal file
583
vrc-get-gui/app/_main/projects/manage/-unity-migration.tsx
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
import type { NavigateFn } from "@tanstack/react-router";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { UnitySelectorDialog } from "@/components/unity-selector-dialog";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriCallUnityForMigrationResult,
|
||||
TauriCopyProjectProgress,
|
||||
TauriUnityVersions,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { callAsyncCommand } from "@/lib/call-async-command";
|
||||
import { VRCSDK_UNITY_VERSIONS } from "@/lib/constants";
|
||||
import { type DialogContext, openSingleDialog, showDialog } from "@/lib/dialog";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
|
||||
|
||||
export async function unityVersionChange({
|
||||
version: targetUnityVersion,
|
||||
currentUnityVersion,
|
||||
isVRCProject,
|
||||
mayUseChinaVariant = false,
|
||||
projectPath,
|
||||
navigate,
|
||||
}: {
|
||||
version: string;
|
||||
currentUnityVersion: string;
|
||||
isVRCProject: boolean;
|
||||
mayUseChinaVariant?: boolean;
|
||||
projectPath: string;
|
||||
navigate: NavigateFn;
|
||||
}) {
|
||||
try {
|
||||
const data = detectChangeUnityKind(
|
||||
currentUnityVersion,
|
||||
targetUnityVersion,
|
||||
isVRCProject,
|
||||
);
|
||||
|
||||
if (await commands.projectIsUnityLaunching(projectPath)) {
|
||||
toastError(tt("projects:toast:close unity before migration"));
|
||||
return;
|
||||
}
|
||||
const header = headerText(data);
|
||||
|
||||
const unityVersions = await commands.environmentUnityVersions();
|
||||
const findResult = findUnityForUnityChange(
|
||||
unityVersions,
|
||||
targetUnityVersion,
|
||||
mayUseChinaVariant,
|
||||
);
|
||||
if (!findResult.found) {
|
||||
await openSingleDialog(NoExactUnity2022Dialog, {
|
||||
expectedVersion: targetUnityVersion,
|
||||
installWithUnityHubLink: findResult.installLink,
|
||||
header,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
using dialog = showDialog();
|
||||
|
||||
let backupTypePromise: Promise<ProjectBackupType | null>;
|
||||
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
|
||||
// for supported migrations, show dialog same as migration
|
||||
switch (data.kind) {
|
||||
case "upgradePatchOrMinor":
|
||||
backupTypePromise = dialog.ask(MigrationConfirmMigrationPatchDialog, {
|
||||
header,
|
||||
unity: targetUnityVersion,
|
||||
});
|
||||
break;
|
||||
case "upgradeMajor":
|
||||
backupTypePromise = dialog.ask(MigrationConfirmMigrationDialog, {
|
||||
header,
|
||||
});
|
||||
}
|
||||
}
|
||||
backupTypePromise ??= dialog.ask(UnityVersionChange, {
|
||||
data,
|
||||
header,
|
||||
});
|
||||
const backupType = await backupTypePromise;
|
||||
if (backupType == null) return;
|
||||
|
||||
let unityPath: string;
|
||||
if (findResult.installations.length === 1) {
|
||||
unityPath = findResult.installations[0][0];
|
||||
} else {
|
||||
const selected = await dialog.ask(UnitySelectorDialog, {
|
||||
unityVersions: findResult.installations,
|
||||
});
|
||||
if (selected == null) return;
|
||||
unityPath = selected.unityPath;
|
||||
}
|
||||
|
||||
let migrateProjectPath: string;
|
||||
switch (backupType) {
|
||||
case "none":
|
||||
migrateProjectPath = projectPath;
|
||||
break;
|
||||
case "copy": {
|
||||
migrateProjectPath = await dialog.ask(MigrationCopyingDialog, {
|
||||
projectPath,
|
||||
header,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "backupArchive": {
|
||||
const result = await dialog.ask(BackupProjectDialog, {
|
||||
projectPath,
|
||||
header,
|
||||
});
|
||||
if (result === "cancelled") return;
|
||||
migrateProjectPath = projectPath;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(backupType);
|
||||
}
|
||||
dialog.replace(<MigrationMigratingDialog header={header} />);
|
||||
|
||||
if (
|
||||
data.isVRC &&
|
||||
data.kind === "upgradeMajor" &&
|
||||
targetUnityVersion.startsWith("2022.")
|
||||
) {
|
||||
await commands.projectMigrateProjectTo2022(migrateProjectPath);
|
||||
}
|
||||
|
||||
const finalizeResult = await dialog.askClosing(
|
||||
MigrationCallingUnityForMigrationDialog,
|
||||
{
|
||||
unityPath,
|
||||
migrateProjectPath,
|
||||
header,
|
||||
},
|
||||
);
|
||||
|
||||
if (finalizeResult === "cancelled") {
|
||||
throw new Error("unexpectedly cancelled");
|
||||
}
|
||||
switch (finalizeResult.type) {
|
||||
case "ExistsWithNonZero":
|
||||
toastError(tt("projects:toast:unity exits with non-zero"));
|
||||
break;
|
||||
case "FinishedSuccessfully":
|
||||
toastSuccess(tt("projects:toast:unity migrated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(finalizeResult);
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["environmentProjects"],
|
||||
}),
|
||||
]);
|
||||
if (migrateProjectPath !== projectPath) {
|
||||
await navigate({
|
||||
replace: true,
|
||||
to: "/projects/manage",
|
||||
search: { projectPath: migrateProjectPath },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function headerText(data: ChangeUnityData) {
|
||||
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
|
||||
switch (data.kind) {
|
||||
case "upgradePatchOrMinor":
|
||||
case "upgradeMajor":
|
||||
return tc("projects:manage:dialog:unity migrate header");
|
||||
}
|
||||
}
|
||||
|
||||
return tc("projects:manage:dialog:unity change version header");
|
||||
}
|
||||
|
||||
function NoExactUnity2022Dialog({
|
||||
expectedVersion,
|
||||
installWithUnityHubLink,
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
expectedVersion: string;
|
||||
installWithUnityHubLink?: string;
|
||||
dialog: DialogContext<void>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p>
|
||||
{tc(
|
||||
"projects:manage:dialog:exact version unity not found for patch migration description",
|
||||
{ unity: expectedVersion },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className={"gap-2"}>
|
||||
{installWithUnityHubLink && (
|
||||
<Button
|
||||
onClick={() => void commands.utilOpenUrl(installWithUnityHubLink)}
|
||||
>
|
||||
{tc("projects:dialog:open unity hub")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => dialog.close()} className="mr-1">
|
||||
{tc("general:button:close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationConfirmMigrationPatchDialog({
|
||||
unity,
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
unity: string;
|
||||
dialog: DialogContext<ProjectBackupType | null>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:dialog:migrate unity2022 patch description", { unity })}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(null)} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationConfirmMigrationDialog({
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
dialog: DialogContext<ProjectBackupType | null>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:dialog:vpm migrate description")}</p>
|
||||
</div>
|
||||
<DialogFooter className={"gap-1"}>
|
||||
<Button onClick={() => dialog.close(null)}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("backupArchive")}>
|
||||
{tc("projects:button:backup and migrate")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("copy")}>
|
||||
{tc("projects:button:migrate copy")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
|
||||
{tc("projects:button:migrate in-place")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityVersionChange({
|
||||
data,
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
data: ChangeUnityData;
|
||||
dialog: DialogContext<ProjectBackupType | null>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
let mainMessage: React.ReactNode;
|
||||
|
||||
if (data.kind === "changeChina") {
|
||||
mainMessage = tc("projects:manage:dialog:changing china releases");
|
||||
} else {
|
||||
const category = {
|
||||
downgradeMajor: "downgrade major",
|
||||
downgradePatchOrMinor: "downgrade minor",
|
||||
upgradePatchOrMinor: "upgrade minor",
|
||||
upgradeMajor: "upgrade major",
|
||||
}[data.kind];
|
||||
if (data.isVRC) {
|
||||
if (data.isTargetVersionSupportedByVRC) {
|
||||
mainMessage = tc([
|
||||
`projects:manage:dialog:${category} vrchat supported`,
|
||||
`projects:manage:dialog:${category}`,
|
||||
]);
|
||||
} else {
|
||||
mainMessage = tc([
|
||||
`projects:manage:dialog:${category} vrchat unsupported`,
|
||||
`projects:manage:dialog:${category}`,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
mainMessage = tc(`projects:manage:dialog:${category}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p className={"text-destructive"}>{mainMessage}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(null)} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
|
||||
{tc("projects:button:change unity version")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MigrationCopyingDialog({
|
||||
projectPath,
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
projectPath: string;
|
||||
dialog: DialogContext<string>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
const [progress, setProgress] = useState<TauriCopyProjectProgress>({
|
||||
proceed: 0,
|
||||
total: 1,
|
||||
last_proceed: "Collecting files...",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const [_, promise] = callAsyncCommand(
|
||||
commands.environmentCopyProjectForMigration,
|
||||
[projectPath],
|
||||
(progress) => {
|
||||
setProgress((prev) => {
|
||||
if (prev.proceed > progress.proceed) return prev;
|
||||
return progress;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
promise.then(dialog.close, dialog.error);
|
||||
}, [projectPath, dialog.close, dialog.error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:pre-migrate copying...")}</p>
|
||||
<p>
|
||||
{tc("projects:dialog:proceed k/n", {
|
||||
count: progress.proceed,
|
||||
total: progress.total,
|
||||
})}
|
||||
</p>
|
||||
<Progress value={progress.proceed} max={progress.total} />
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationMigratingDialog({ header }: { header: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:migrating...")}</p>
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type UnityInstallation = [path: string, version: string, fromHub: boolean];
|
||||
|
||||
type FindUnityResult = FindUnityFoundResult | FindUnityNotFoundResult;
|
||||
|
||||
type ProjectBackupType = "none" | "copy" | "backupArchive";
|
||||
|
||||
interface FindUnityFoundResult {
|
||||
found: true;
|
||||
installations: UnityInstallation[];
|
||||
}
|
||||
|
||||
interface FindUnityNotFoundResult {
|
||||
installLink?: string;
|
||||
found: false;
|
||||
}
|
||||
|
||||
type ChangeUnityKind =
|
||||
| "changeChina" // Changing between 'c' releases and non 'c' releases
|
||||
| "downgradeMajor"
|
||||
| "downgradePatchOrMinor"
|
||||
| "upgradePatchOrMinor"
|
||||
| "upgradeMajor";
|
||||
|
||||
type ChangeUnityData =
|
||||
| {
|
||||
kind: ChangeUnityKind;
|
||||
isVRC: false;
|
||||
}
|
||||
| {
|
||||
kind: ChangeUnityKind;
|
||||
isVRC: true;
|
||||
isTargetVersionSupportedByVRC: boolean;
|
||||
};
|
||||
|
||||
function detectChangeUnityKind(
|
||||
currentVersion: string,
|
||||
targetUnityVersion: string,
|
||||
isVRCProject: boolean,
|
||||
): ChangeUnityData {
|
||||
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
|
||||
const parsedCurrent = parseUnityVersion(currentVersion)!;
|
||||
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
|
||||
const parsedTarget = parseUnityVersion(targetUnityVersion)!;
|
||||
|
||||
const cmp = compareUnityVersionString(currentVersion, targetUnityVersion);
|
||||
const majorOrMinor =
|
||||
parsedCurrent.major === parsedTarget.major ? "PatchOrMinor" : "Major";
|
||||
|
||||
const kind: ChangeUnityData["kind"] =
|
||||
cmp === 0
|
||||
? "changeChina"
|
||||
: cmp > 0
|
||||
? `downgrade${majorOrMinor}`
|
||||
: `upgrade${majorOrMinor}`;
|
||||
|
||||
if (isVRCProject) {
|
||||
return {
|
||||
kind,
|
||||
isVRC: true,
|
||||
isTargetVersionSupportedByVRC:
|
||||
VRCSDK_UNITY_VERSIONS.includes(targetUnityVersion),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
kind,
|
||||
isVRC: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findUnityForUnityChange(
|
||||
unityVersions: TauriUnityVersions,
|
||||
targetUnityVersion: string,
|
||||
mayUseChinaVariant: boolean,
|
||||
): FindUnityResult {
|
||||
let foundVersions = unityVersions.unity_paths.filter(
|
||||
([_p, v, _]) => v === targetUnityVersion,
|
||||
);
|
||||
// if international version not found, try to find china version
|
||||
if (
|
||||
foundVersions.length === 0 &&
|
||||
mayUseChinaVariant &&
|
||||
parseUnityVersion(targetUnityVersion)?.chinaIncrement == null
|
||||
) {
|
||||
const chinaVersion = `${targetUnityVersion}c1`;
|
||||
foundVersions = unityVersions.unity_paths.filter(
|
||||
([_p, v, _]) => v === chinaVersion,
|
||||
);
|
||||
}
|
||||
if (foundVersions.length === 0) {
|
||||
if (
|
||||
compareUnityVersionString(
|
||||
targetUnityVersion,
|
||||
unityVersions.recommended_version,
|
||||
) === 0
|
||||
) {
|
||||
return {
|
||||
// This is using link to international version but china version of hub will handle international to china conversion
|
||||
installLink: unityVersions.install_recommended_version_link,
|
||||
found: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
found: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
installations: foundVersions,
|
||||
};
|
||||
}
|
||||
|
||||
function MigrationCallingUnityForMigrationDialog({
|
||||
unityPath,
|
||||
migrateProjectPath,
|
||||
dialog,
|
||||
header,
|
||||
}: {
|
||||
unityPath: string;
|
||||
migrateProjectPath: string;
|
||||
dialog: DialogContext<"cancelled" | TauriCallUnityForMigrationResult>;
|
||||
header: React.ReactNode;
|
||||
}) {
|
||||
const [lines, setLines] = useState<[number, string][]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let lineNumber = 0;
|
||||
const [, promise] = callAsyncCommand(
|
||||
commands.projectCallUnityForMigration,
|
||||
[migrateProjectPath, unityPath],
|
||||
(lineString) => {
|
||||
setLines((prev) => {
|
||||
lineNumber++;
|
||||
const line: [number, string] = [lineNumber, lineString];
|
||||
if (prev.length > 200) {
|
||||
return [...prev.slice(1), line];
|
||||
} else {
|
||||
return [...prev, line];
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
promise.then(dialog.close, dialog.error);
|
||||
}, [migrateProjectPath, unityPath, dialog]);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to scroll to bottom on lines changed
|
||||
React.useEffect(() => {
|
||||
ref.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
<div>
|
||||
<p>{tc("projects:manage:dialog:unity migrate finalizing...")}</p>
|
||||
<p>{tc("projects:do not close")}</p>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<pre
|
||||
className={
|
||||
"overflow-y-auto h-[50vh] bg-secondary text-secondary-foreground text-sm"
|
||||
}
|
||||
>
|
||||
{lines.map(([lineNumber, line]) => (
|
||||
<Fragment key={lineNumber}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</Fragment>
|
||||
))}
|
||||
<div ref={ref} />
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
796
vrc-get-gui/app/_main/projects/manage/-use-package-change.tsx
Normal file
796
vrc-get-gui/app/_main/projects/manage/-use-package-change.tsx
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
import type { DefaultError } from "@tanstack/query-core";
|
||||
import { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Fragment } from "react";
|
||||
import { DelayedButton } from "@/components/DelayedButton";
|
||||
import { ExternalLink } from "@/components/ExternalLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type {
|
||||
TauriBasePackageInfo,
|
||||
TauriPackage,
|
||||
TauriPackageChange,
|
||||
TauriPendingProjectChanges,
|
||||
TauriVersion,
|
||||
} from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
|
||||
import { isHandleable } from "@/lib/errors";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { toastInfo, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { groupBy, keyComparator } from "@/lib/utils";
|
||||
import { compareVersion, toVersionString } from "@/lib/version";
|
||||
|
||||
export type RequestedOperation =
|
||||
| {
|
||||
type: "install";
|
||||
pkg: TauriPackage;
|
||||
hasUnityIncompatibleLatest?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "upgradeAll";
|
||||
hasUnityIncompatibleLatest: boolean;
|
||||
packages: TauriPackage[];
|
||||
}
|
||||
| {
|
||||
type: "resolve";
|
||||
}
|
||||
| {
|
||||
type: "reinstallAll";
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
displayName: string;
|
||||
packageId: string;
|
||||
}
|
||||
| {
|
||||
type: "bulkInstalled";
|
||||
hasUnityIncompatibleLatest: boolean;
|
||||
packages: TauriPackage[];
|
||||
}
|
||||
| {
|
||||
type: "bulkReinstalled";
|
||||
packageIds: string[];
|
||||
}
|
||||
| {
|
||||
type: "bulkRemoved";
|
||||
packageIds: string[];
|
||||
};
|
||||
|
||||
function environmentPackages(projectPath: string) {
|
||||
return queryOptions({
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
queryFn: () => commands.projectDetails(projectPath),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function mutationOptions<
|
||||
TOptions extends UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options: TOptions & UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
): TOptions {
|
||||
return options;
|
||||
}
|
||||
|
||||
export function applyChangesMutation(projectPath: string) {
|
||||
return mutationOptions({
|
||||
mutationKey: ["projectApplyChanges", projectPath],
|
||||
mutationFn: async (operation: RequestedOperation) =>
|
||||
await applyChanges(projectPath, operation),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSettled: async () => {
|
||||
document.dispatchEvent(new Event("post-package-changes"));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentPackages"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyChanges(
|
||||
projectPath: string,
|
||||
operation: RequestedOperation,
|
||||
) {
|
||||
try {
|
||||
const existingPackages = queryClient.getQueryData(
|
||||
environmentPackages(projectPath).queryKey,
|
||||
)?.installed_packages;
|
||||
|
||||
const changes = await createChanges(projectPath, operation);
|
||||
if (
|
||||
!(await openSingleDialog(ProjectChangesDialog, {
|
||||
changes,
|
||||
existingPackages,
|
||||
}))
|
||||
) {
|
||||
// close window
|
||||
return;
|
||||
}
|
||||
await commands.projectApplyPendingChanges(
|
||||
projectPath,
|
||||
changes.changes_version,
|
||||
);
|
||||
showToast(operation);
|
||||
} catch (e) {
|
||||
if (isHandleable(e) && e.body.type === "MissingDependencies") {
|
||||
await openSingleDialog(MissingDependenciesDialog, {
|
||||
dependencies: e.body.dependencies,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChanges(
|
||||
projectPath: string,
|
||||
operation: RequestedOperation,
|
||||
): Promise<TauriPendingProjectChanges> {
|
||||
switch (operation.type) {
|
||||
case "install":
|
||||
return commands.projectInstallPackages(projectPath, [
|
||||
[operation.pkg.name, toVersionString(operation.pkg.version)],
|
||||
]);
|
||||
case "upgradeAll":
|
||||
return commands.projectInstallPackages(
|
||||
projectPath,
|
||||
operation.packages.map((pkg) => [
|
||||
pkg.name,
|
||||
toVersionString(pkg.version),
|
||||
]),
|
||||
);
|
||||
case "resolve":
|
||||
case "reinstallAll":
|
||||
return commands.projectResolve(projectPath);
|
||||
case "remove":
|
||||
return commands.projectRemovePackages(projectPath, [operation.packageId]);
|
||||
case "bulkInstalled":
|
||||
return commands.projectInstallPackages(
|
||||
projectPath,
|
||||
operation.packages.map((pkg) => [
|
||||
pkg.name,
|
||||
toVersionString(pkg.version),
|
||||
]),
|
||||
);
|
||||
case "bulkReinstalled":
|
||||
return commands.projectReinstallPackages(
|
||||
projectPath,
|
||||
operation.packageIds,
|
||||
);
|
||||
case "bulkRemoved":
|
||||
return commands.projectRemovePackages(projectPath, operation.packageIds);
|
||||
default:
|
||||
assertNever(operation);
|
||||
}
|
||||
}
|
||||
function showToast(requested: RequestedOperation) {
|
||||
switch (requested.type) {
|
||||
case "install":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:package installed", {
|
||||
name: requested.pkg.display_name ?? requested.pkg.name,
|
||||
version: toVersionString(requested.pkg.version),
|
||||
}),
|
||||
);
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:the package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "remove":
|
||||
toastSuccess(
|
||||
tt("projects:manage:toast:package removed", {
|
||||
name: requested.displayName,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "resolve":
|
||||
toastSuccess(tt("projects:manage:toast:resolved"));
|
||||
break;
|
||||
case "reinstallAll":
|
||||
toastSuccess(tt("projects:manage:toast:all packages reinstalled"));
|
||||
break;
|
||||
case "upgradeAll":
|
||||
toastSuccess(tt("projects:manage:toast:all packages upgraded"));
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:some package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "bulkInstalled":
|
||||
toastSuccess(tt("projects:manage:toast:selected packages installed"));
|
||||
if (requested.hasUnityIncompatibleLatest) {
|
||||
toastInfo(
|
||||
tt(
|
||||
"projects:manage:toast:some package has newer latest with incompatible unity",
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "bulkRemoved":
|
||||
toastSuccess(tt("projects:manage:toast:selected packages removed"));
|
||||
break;
|
||||
case "bulkReinstalled":
|
||||
toastSuccess(tt("projects:manage:toast:selected packages reinstalled"));
|
||||
break;
|
||||
default:
|
||||
assertNever(requested);
|
||||
}
|
||||
}
|
||||
|
||||
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className={"p-3"}>
|
||||
<p className={"font-normal"}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
function ProjectChangesDialog({
|
||||
changes,
|
||||
existingPackages,
|
||||
dialog,
|
||||
}: {
|
||||
changes: TauriPendingProjectChanges;
|
||||
existingPackages?: [string, TauriBasePackageInfo][];
|
||||
dialog: DialogContext<boolean>;
|
||||
}) {
|
||||
const versionConflicts = changes.conflicts.filter(
|
||||
([_, c]) => c.packages.length > 0,
|
||||
);
|
||||
const unityConflicts = changes.conflicts.filter(([_, c]) => c.unity_conflict);
|
||||
const unlockedConflicts = changes.conflicts.flatMap(
|
||||
([_, c]) => c.unlocked_names,
|
||||
);
|
||||
|
||||
const existingPackageMap = new Map(existingPackages ?? []);
|
||||
|
||||
const categorizedChanges = changes.package_changes.map(([pkgId, change]) =>
|
||||
categorizeChange(pkgId, change, existingPackageMap),
|
||||
);
|
||||
categorizedChanges.sort(keyComparator("packageId"));
|
||||
const groupedChanges = Array.from(groupBy(categorizedChanges, (c) => c.type));
|
||||
groupedChanges.sort(keyComparator(0));
|
||||
|
||||
const installingPackageById = new Map(
|
||||
changes.package_changes
|
||||
.map(([id, change]) =>
|
||||
"InstallNew" in change ? ([id, change.InstallNew] as const) : undefined,
|
||||
)
|
||||
.filter((x) => x != null),
|
||||
);
|
||||
|
||||
function getPackageDisplayName(id: string) {
|
||||
return (
|
||||
installingPackageById.get(id)?.display_name ??
|
||||
existingPackageMap.get(id)?.display_name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
const breakingChanges = groupedChanges.some(
|
||||
([a]) => a === PackageChangeCategory.UpgradeMajor,
|
||||
);
|
||||
|
||||
const incompatibility = changes.conflicts.length !== 0;
|
||||
|
||||
const needsCare = breakingChanges || incompatibility;
|
||||
|
||||
return (
|
||||
<div className={"contents whitespace-normal"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tc("projects:manage:button:apply changes")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>{tc("projects:manage:dialog:confirm changes description")}</p>
|
||||
{breakingChanges && (
|
||||
<div
|
||||
className={
|
||||
"flex border border-solid border-warning mt-3 py-2 me-1.5"
|
||||
}
|
||||
>
|
||||
<CircleAlert
|
||||
className={"text-warning self-center mx-2 shrink-0"}
|
||||
/>
|
||||
<p>{tc("projects:manage:dialog:note breaking changes")}</p>
|
||||
</div>
|
||||
)}
|
||||
{incompatibility && (
|
||||
<div
|
||||
className={
|
||||
"flex border border-solid border-warning mt-3 py-2 me-1.5"
|
||||
}
|
||||
>
|
||||
<CircleAlert
|
||||
className={"text-warning self-center mx-2 shrink-0"}
|
||||
/>
|
||||
<p>{tc("projects:manage:dialog:note incompatibility")}</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-hidden flex">
|
||||
<ScrollArea
|
||||
type="always"
|
||||
className={"w-full"}
|
||||
scrollBarClassName={"bg-background pb-2.5"}
|
||||
>
|
||||
<div className="pr-2 overflow-x-hidden">
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{groupedChanges.map(([category, changes], index) => {
|
||||
return (
|
||||
<Fragment key={category}>
|
||||
{index !== 0 && <hr />}
|
||||
{changes.map((change) => (
|
||||
<PackageChange key={change.packageId} change={change} />
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{versionConflicts.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:manage:dialog:package version conflicts", {
|
||||
count: versionConflicts.length,
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{versionConflicts.map(([pkgId, conflict]) => {
|
||||
return (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc("projects:manage:dialog:conflicts with", {
|
||||
pkg: getPackageDisplayName(pkgId),
|
||||
other: conflict.packages
|
||||
.map((p) => getPackageDisplayName(p))
|
||||
.join(", "),
|
||||
})}
|
||||
</TypographyItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{unityConflicts.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc("projects:manage:dialog:unity version conflicts", {
|
||||
count: unityConflicts.length,
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{unityConflicts.map(([pkgId, _]) => (
|
||||
<TypographyItem key={pkgId}>
|
||||
{tc(
|
||||
"projects:manage:dialog:package not supported your unity",
|
||||
{
|
||||
pkg: getPackageDisplayName(pkgId),
|
||||
},
|
||||
)}
|
||||
</TypographyItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{changes.remove_legacy_files.length > 0 ||
|
||||
changes.remove_legacy_folders.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc(
|
||||
"projects:manage:dialog:files and directories are removed as legacy",
|
||||
)}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{changes.remove_legacy_files.map((f) => (
|
||||
<TypographyItem key={f}>{f}</TypographyItem>
|
||||
))}
|
||||
{changes.remove_legacy_folders.map((f) => (
|
||||
<TypographyItem key={f}>{f}</TypographyItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{unlockedConflicts.length > 0 ? (
|
||||
<>
|
||||
<p className={"text-destructive"}>
|
||||
{tc(
|
||||
"projects:manage:dialog:packages installed in the following directories will be removed",
|
||||
)}
|
||||
</p>
|
||||
<div className={"flex flex-col gap-1 p-2"}>
|
||||
{unlockedConflicts.map((f) => (
|
||||
<TypographyItem key={f}>{f}</TypographyItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(false)} className="mr-1">
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<DelayedButton
|
||||
onClick={() => dialog.close(true)}
|
||||
variant={needsCare ? "destructive" : "warning"}
|
||||
delay={needsCare ? 1000 : 0}
|
||||
>
|
||||
{tc("projects:manage:button:apply")}
|
||||
</DelayedButton>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageChange({
|
||||
change,
|
||||
}: {
|
||||
change: PackageChangeDisplayInformation;
|
||||
}) {
|
||||
switch (change.type) {
|
||||
case PackageChangeCategory.UpgradeMajor:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between bg-warning/10"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:upgrade package", {
|
||||
name: change.displayName,
|
||||
previousVersion: toVersionString(change.previousVersion),
|
||||
version: toVersionString(change.version),
|
||||
})}
|
||||
<span className={"text-warning"}>
|
||||
{"\u200B"}
|
||||
<CircleAlert
|
||||
className={
|
||||
"inline px-1 size-5 -mt-0.5 box-content align-middle"
|
||||
}
|
||||
/>
|
||||
{tc("projects:manage:dialog:breaking changes")}
|
||||
</span>
|
||||
</p>
|
||||
<ChangelogButton url={change.changelogUrl} />
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.Upgrade:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:upgrade package", {
|
||||
name: change.displayName,
|
||||
previousVersion: toVersionString(change.previousVersion),
|
||||
version: toVersionString(change.version),
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={change.changelogUrl} />
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.Downgrade:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:downgrade package", {
|
||||
name: change.displayName,
|
||||
previousVersion: toVersionString(change.previousVersion),
|
||||
version: toVersionString(change.version),
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={change.changelogUrl} />
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.InstallNew:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:install package", {
|
||||
name: change.displayName,
|
||||
version: toVersionString(change.version),
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={change.changelogUrl} />
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.UninstallRequested:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:uninstall package as requested", {
|
||||
name: change.displayName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.UninstallUnused:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:uninstall package as unused", {
|
||||
name: change.displayName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.UninstallLegacy:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal"}>
|
||||
{tc("projects:manage:dialog:uninstall package as legacy", {
|
||||
name: change.displayName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case PackageChangeCategory.Reinstall:
|
||||
return (
|
||||
<div className={"flex items-center p-3 justify-between"}>
|
||||
<p className={"font-normal select-text"}>
|
||||
{tc("projects:manage:dialog:reinstall package", {
|
||||
name: change.displayName,
|
||||
version: toVersionString(change.version),
|
||||
})}
|
||||
</p>
|
||||
<ChangelogButton url={change.changelogUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum PackageChangeCategory {
|
||||
InstallNew = 0,
|
||||
UpgradeMajor = 1,
|
||||
Upgrade = 2,
|
||||
Downgrade = 3,
|
||||
UninstallRequested = 4,
|
||||
UninstallUnused = 5,
|
||||
UninstallLegacy = 6,
|
||||
Reinstall = 7,
|
||||
}
|
||||
|
||||
type PackageChangeDisplayInformation = {
|
||||
packageId: string;
|
||||
displayName: string;
|
||||
} & (
|
||||
| {
|
||||
type: PackageChangeCategory.UpgradeMajor;
|
||||
version: TauriVersion;
|
||||
previousVersion: TauriVersion;
|
||||
changelogUrl: string | null;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.Upgrade;
|
||||
version: TauriVersion;
|
||||
previousVersion: TauriVersion;
|
||||
changelogUrl: string | null;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.Downgrade;
|
||||
version: TauriVersion;
|
||||
previousVersion: TauriVersion;
|
||||
changelogUrl: string | null;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.Reinstall;
|
||||
version: TauriVersion;
|
||||
changelogUrl: string | null;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.InstallNew;
|
||||
version: TauriVersion;
|
||||
changelogUrl: string | null;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.UninstallRequested;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.UninstallUnused;
|
||||
}
|
||||
| {
|
||||
type: PackageChangeCategory.UninstallLegacy;
|
||||
}
|
||||
);
|
||||
|
||||
function categorizeChange(
|
||||
pkgId: string,
|
||||
change: TauriPackageChange,
|
||||
installedPackages: Map<string, TauriBasePackageInfo>,
|
||||
): PackageChangeDisplayInformation {
|
||||
if (change.InstallNew !== undefined) {
|
||||
const name = change.InstallNew.display_name ?? change.InstallNew.name;
|
||||
|
||||
const installed = installedPackages.get(pkgId);
|
||||
if (installed == null) {
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.InstallNew,
|
||||
version: change.InstallNew.version,
|
||||
changelogUrl: change.InstallNew.changelog_url,
|
||||
};
|
||||
} else {
|
||||
const compare = compareVersion(
|
||||
installed.version,
|
||||
change.InstallNew.version,
|
||||
);
|
||||
switch (compare) {
|
||||
case 1:
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.Downgrade,
|
||||
version: change.InstallNew.version,
|
||||
previousVersion: installed.version,
|
||||
changelogUrl: change.InstallNew.changelog_url,
|
||||
};
|
||||
case 0:
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.Reinstall,
|
||||
version: change.InstallNew.version,
|
||||
changelogUrl: change.InstallNew.changelog_url,
|
||||
};
|
||||
case -1:
|
||||
if (
|
||||
isUpgradingMajorly(
|
||||
pkgId,
|
||||
installed.version,
|
||||
change.InstallNew.version,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.UpgradeMajor,
|
||||
version: change.InstallNew.version,
|
||||
previousVersion: installed.version,
|
||||
changelogUrl: change.InstallNew.changelog_url,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.Upgrade,
|
||||
version: change.InstallNew.version,
|
||||
previousVersion: installed.version,
|
||||
changelogUrl: change.InstallNew.changelog_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const name = installedPackages.get(pkgId)?.display_name ?? pkgId;
|
||||
switch (change.Remove) {
|
||||
case "Requested":
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.UninstallRequested,
|
||||
};
|
||||
case "Legacy":
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.UninstallLegacy,
|
||||
};
|
||||
case "Unused":
|
||||
return {
|
||||
packageId: pkgId,
|
||||
displayName: name,
|
||||
type: PackageChangeCategory.UninstallUnused,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUpgradingMajorly(
|
||||
pkgId: string,
|
||||
prevVersion: TauriVersion,
|
||||
newVersion: TauriVersion,
|
||||
): boolean {
|
||||
function firstNonZeroVersionNum(version: TauriVersion): number {
|
||||
if (version.major !== 0) return version.major;
|
||||
if (version.minor !== 0) return version.minor;
|
||||
return version.patch;
|
||||
}
|
||||
|
||||
// generic case: non-zero first version number will be the major version
|
||||
if (
|
||||
firstNonZeroVersionNum(prevVersion) !== firstNonZeroVersionNum(newVersion)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Special case: VRChat SDK uses Branding.Breaking.Bumps.
|
||||
// Therefore the second number bump means major version bump.
|
||||
// See https://vcc.docs.vrchat.com/vpm/packages/#brandingbreakingbumps
|
||||
// See https://feedback.vrchat.com/sdk-bug-reports/p/feedback-please-dont-make-vrcsdk-to-4x-unless-as-big-breaking-changes-as-2-to-3
|
||||
if (
|
||||
pkgId === "com.vrchat.avatars" ||
|
||||
pkgId === "com.vrchat.worlds" ||
|
||||
pkgId === "com.vrchat.base"
|
||||
) {
|
||||
if (prevVersion.minor !== newVersion.minor) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No conditions met so it's not major bump
|
||||
return false;
|
||||
}
|
||||
|
||||
function ChangelogButton({ url }: { url?: string | null }) {
|
||||
if (url == null) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return (
|
||||
<Button
|
||||
className={"ml-1 px-2"}
|
||||
size={"sm"}
|
||||
onClick={() => commands.utilOpenUrl(url)}
|
||||
>
|
||||
<ExternalLink>
|
||||
{tc("projects:manage:button:see changelog")}
|
||||
</ExternalLink>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MissingDependenciesDialog({
|
||||
dependencies,
|
||||
dialog,
|
||||
}: {
|
||||
dependencies: [pkg: string, range: string][];
|
||||
dialog: DialogContext<void>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<DialogTitle className={"text-destructive"}>
|
||||
<CircleAlert className="size-6 inline" />{" "}
|
||||
{tc("projects:manage:dialog:missing dependencies")}
|
||||
</DialogTitle>
|
||||
<div>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("projects:manage:dialog:missing dependencies description")}
|
||||
</p>
|
||||
<ul className={"list-disc ml-4 mt-2"}>
|
||||
{dependencies.map(([dep, range]) => (
|
||||
<li key={dep}>
|
||||
{dep} version {range}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close()}>
|
||||
{tc("general:button:close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
771
vrc-get-gui/app/_main/projects/manage/index.tsx
Normal file
771
vrc-get-gui/app/_main/projects/manage/index.tsx
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
type UseQueryResult,
|
||||
useIsMutating,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createFileRoute,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronDown } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
|
||||
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
|
||||
import { HNavBar, VStack } from "@/components/layout";
|
||||
import { OpenUnityButton } from "@/components/OpenUnityButton";
|
||||
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
UnityArgumentsSettings,
|
||||
useUnityArgumentsSettings,
|
||||
} from "@/components/unity-arguments-settings";
|
||||
import type { TauriProjectDetails, TauriUnityVersions } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { VRCSDK_PACKAGES, VRCSDK_UNITY_VERSIONS } from "@/lib/constants";
|
||||
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { nameFromPath } from "@/lib/os";
|
||||
import { toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
|
||||
import { combinePackagesAndProjectDetails } from "./-collect-package-row-info";
|
||||
import { PackageListCard } from "./-package-list-card";
|
||||
import { PageContextProvider } from "./-page-context";
|
||||
import { unityVersionChange } from "./-unity-migration";
|
||||
import { applyChangesMutation } from "./-use-package-change";
|
||||
|
||||
interface SearchParams {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_main/projects/manage/")({
|
||||
component: Page,
|
||||
validateSearch: (a): SearchParams => ({
|
||||
projectPath: a.projectPath == null ? "" : `${a.projectPath}`,
|
||||
}),
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PageBody() {
|
||||
const { projectPath } = Route.useSearch();
|
||||
const router = useRouter();
|
||||
|
||||
// repositoriesInfo: list of repositories and their visibility
|
||||
// packagesResult: list of packages
|
||||
// detailsResult: project details including installed packages
|
||||
// unityVersionsResult: list of unity versions installed
|
||||
const [repositoriesInfo, packagesResult, detailsResult, unityVersionsResult] =
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
queryFn: commands.environmentRepositoriesInfo,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["environmentPackages"],
|
||||
queryFn: commands.environmentPackages,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
queryFn: () => commands.projectDetails(projectPath),
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
{
|
||||
queryKey: ["environmentUnityVersions"],
|
||||
queryFn: () => commands.environmentUnityVersions(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const packageRowsData = useMemo(() => {
|
||||
const packages = packagesResult.data ?? [];
|
||||
const details = detailsResult.data ?? null;
|
||||
const hiddenRepositories =
|
||||
repositoriesInfo.data?.hidden_user_repositories ?? [];
|
||||
const hideUserPackages =
|
||||
repositoriesInfo.data?.hide_local_user_packages ?? false;
|
||||
const definedRepositories = repositoriesInfo.data?.user_repositories ?? [];
|
||||
const showPrereleasePackages =
|
||||
repositoriesInfo.data?.show_prerelease_packages ?? false;
|
||||
return combinePackagesAndProjectDetails(
|
||||
packages,
|
||||
details,
|
||||
hiddenRepositories,
|
||||
hideUserPackages,
|
||||
definedRepositories,
|
||||
showPrereleasePackages,
|
||||
);
|
||||
}, [repositoriesInfo.data, packagesResult.data, detailsResult.data]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refetchPackages = useMutation({
|
||||
mutationFn: async () => await commands.environmentRefetchPackages(),
|
||||
onError: (e) => {
|
||||
reportError(e);
|
||||
console.error(e);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["environmentRepositoriesInfo"],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ["environmentPackages"] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projectDetails", projectPath],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["environmentUnityVersions"],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const fetchingMutation = useIsMutating({
|
||||
mutationKey: applyChangesMutation(projectPath).mutationKey,
|
||||
});
|
||||
|
||||
const requestChangeUnityVersion = (
|
||||
version: string,
|
||||
mayUseChinaVariant?: boolean,
|
||||
) => {
|
||||
if (detailsResult.data == null)
|
||||
throw new Error("Project details not ready");
|
||||
const isVRCProject = detailsResult.data.installed_packages.some(([id, _]) =>
|
||||
VRCSDK_PACKAGES.includes(id),
|
||||
);
|
||||
void unityVersionChange({
|
||||
projectPath,
|
||||
version,
|
||||
isVRCProject,
|
||||
currentUnityVersion: detailsResult.data.unity_str ?? "unknown",
|
||||
mayUseChinaVariant,
|
||||
navigate: router.navigate,
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
packagesResult.isFetching ||
|
||||
detailsResult.isFetching ||
|
||||
repositoriesInfo.isFetching ||
|
||||
unityVersionsResult.isLoading ||
|
||||
fetchingMutation !== 0 ||
|
||||
refetchPackages.isPending;
|
||||
|
||||
console.log(`rerender: isloading: ${isLoading}`);
|
||||
|
||||
const pageContext = useMemo(() => ({ isLoading }), [isLoading]);
|
||||
|
||||
return (
|
||||
<PageContextProvider value={pageContext}>
|
||||
<VStack>
|
||||
<ProjectViewHeader
|
||||
className="shrink-0"
|
||||
isLoading={isLoading}
|
||||
detailsResult={detailsResult}
|
||||
unityVersionsResult={unityVersionsResult}
|
||||
requestChangeUnityVersion={requestChangeUnityVersion}
|
||||
/>
|
||||
{detailsResult?.data?.should_resolve && (
|
||||
<SuggestResolveProjectCard disabled={isLoading} />
|
||||
)}
|
||||
<MigrationCards
|
||||
isLoading={isLoading}
|
||||
detailsResult={detailsResult.data}
|
||||
unityVersionsResult={unityVersionsResult.data}
|
||||
requestChangeUnityVersion={requestChangeUnityVersion}
|
||||
/>
|
||||
<main className="shrink overflow-hidden flex w-full h-full">
|
||||
<PackageListCard
|
||||
packageRowsData={packageRowsData}
|
||||
repositoriesInfo={repositoriesInfo.data}
|
||||
onRefresh={() => refetchPackages.mutate()}
|
||||
/>
|
||||
</main>
|
||||
</VStack>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityVersionSelector({
|
||||
disabled,
|
||||
detailsResult,
|
||||
requestChangeUnityVersion,
|
||||
unityVersions,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
detailsResult: UseQueryResult<TauriProjectDetails>;
|
||||
requestChangeUnityVersion: (version: string) => void;
|
||||
unityVersions?: TauriUnityVersions;
|
||||
}) {
|
||||
const unityVersionNames = useMemo(() => {
|
||||
if (unityVersions == null) return null;
|
||||
const versionNames = [
|
||||
...new Set<string>(unityVersions.unity_paths.map(([, path]) => path)),
|
||||
];
|
||||
versionNames.sort((a, b) => compareUnityVersionString(b, a));
|
||||
return versionNames;
|
||||
}, [unityVersions]);
|
||||
|
||||
const isVRCProject =
|
||||
detailsResult.data?.installed_packages.some(([id, _]) =>
|
||||
VRCSDK_PACKAGES.includes(id),
|
||||
) ?? false;
|
||||
|
||||
let unityVersionList: React.ReactNode;
|
||||
|
||||
if (unityVersionNames == null) {
|
||||
unityVersionList = <SelectLabel>Loading...</SelectLabel>;
|
||||
} else if (isVRCProject) {
|
||||
const vrcSupportedVersions = unityVersionNames.filter((v) =>
|
||||
VRCSDK_UNITY_VERSIONS.includes(v),
|
||||
);
|
||||
const vrcUnsupportedVersions = unityVersionNames.filter(
|
||||
(v) => !VRCSDK_UNITY_VERSIONS.includes(v),
|
||||
);
|
||||
|
||||
if (
|
||||
vrcUnsupportedVersions.length === 0 ||
|
||||
vrcUnsupportedVersions.length === 0
|
||||
) {
|
||||
unityVersionList = unityVersionNames.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
));
|
||||
} else {
|
||||
// if there are both supported and unsupported versions, show them separately
|
||||
unityVersionList = (
|
||||
<>
|
||||
{vrcSupportedVersions.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
<Separator className={"-ml-6 mr-0 w-auto"} />
|
||||
</SelectLabel>
|
||||
{vrcUnsupportedVersions.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
unityVersionList = unityVersionNames.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={detailsResult.data?.unity_str ?? undefined}
|
||||
onValueChange={requestChangeUnityVersion}
|
||||
>
|
||||
<SelectTrigger className={"compact:h-10"}>
|
||||
{detailsResult.status === "success" ? (
|
||||
(detailsResult.data.unity_str ?? "unknown")
|
||||
) : (
|
||||
<span className={"text-primary"}>Loading...</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>{unityVersionList}</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestResolveProjectCard({ disabled }: { disabled?: boolean }) {
|
||||
const { projectPath } = Route.useSearch();
|
||||
const packageChange = useMutation(applyChangesMutation(projectPath));
|
||||
|
||||
return (
|
||||
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
|
||||
{tc("projects:manage:suggest resolve")}
|
||||
</p>
|
||||
<div className={"grow shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={() => packageChange.mutate({ type: "resolve" })}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:resolve")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationCards({
|
||||
isLoading,
|
||||
detailsResult,
|
||||
unityVersionsResult,
|
||||
requestChangeUnityVersion,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
detailsResult?: TauriProjectDetails;
|
||||
unityVersionsResult?: TauriUnityVersions;
|
||||
requestChangeUnityVersion: (
|
||||
version: string,
|
||||
keepChinaVariant?: boolean,
|
||||
) => void;
|
||||
}) {
|
||||
if (detailsResult == null) return null;
|
||||
if (unityVersionsResult == null) return null;
|
||||
if (detailsResult.unity == null) return false;
|
||||
if (detailsResult.unity_str == null) return false;
|
||||
const currentUnity = detailsResult.unity_str;
|
||||
|
||||
const isVRChatProject = detailsResult.installed_packages.some(([id, _]) =>
|
||||
VRCSDK_PACKAGES.includes(id),
|
||||
);
|
||||
|
||||
// we only migrate VRChat project (for now)
|
||||
if (!isVRChatProject) return null;
|
||||
|
||||
// for 2019 projects, VRChat recommends migrating to 2022
|
||||
const isMigrationTo2022Recommended = detailsResult.unity[0] === 2019;
|
||||
const is2022PatchMigrationRecommended =
|
||||
detailsResult.unity[0] === 2022 &&
|
||||
compareUnityVersionString(
|
||||
detailsResult.unity_str,
|
||||
unityVersionsResult.recommended_version,
|
||||
) !== 0;
|
||||
|
||||
const isChinaToInternationalMigrationRecommended =
|
||||
parseUnityVersion(detailsResult.unity_str)?.chinaIncrement != null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMigrationTo2022Recommended && (
|
||||
<SuggestMigrateTo2022Card
|
||||
disabled={isLoading}
|
||||
onMigrateRequested={() =>
|
||||
requestChangeUnityVersion(
|
||||
unityVersionsResult.recommended_version,
|
||||
true,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{is2022PatchMigrationRecommended && (
|
||||
<Suggest2022PatchMigrationCard
|
||||
disabled={isLoading}
|
||||
onMigrateRequested={() =>
|
||||
requestChangeUnityVersion(
|
||||
unityVersionsResult.recommended_version,
|
||||
true,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isChinaToInternationalMigrationRecommended && (
|
||||
<SuggestChinaToInternationalMigrationCard
|
||||
disabled={isLoading}
|
||||
onMigrateRequested={() => {
|
||||
const internationalVersion = currentUnity.slice(
|
||||
0,
|
||||
currentUnity.indexOf("c"),
|
||||
);
|
||||
requestChangeUnityVersion(internationalVersion);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestMigrateTo2022Card({
|
||||
disabled,
|
||||
onMigrateRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onMigrateRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
|
||||
{tc("projects:manage:suggest unity migration")}
|
||||
</p>
|
||||
<div className={"grow shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onMigrateRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:unity migrate")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Suggest2022PatchMigrationCard({
|
||||
disabled,
|
||||
onMigrateRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onMigrateRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
|
||||
{tc("projects:manage:suggest unity patch migration")}
|
||||
</p>
|
||||
<div className={"grow shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onMigrateRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:unity migrate")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestChinaToInternationalMigrationCard({
|
||||
disabled,
|
||||
onMigrateRequested,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onMigrateRequested: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
|
||||
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
|
||||
{tc("projects:manage:suggest unity china to international migration")}
|
||||
</p>
|
||||
<div className={"grow shrink-0 w-2"} />
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
onClick={onMigrateRequested}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tc("projects:manage:button:unity migrate")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectViewHeader({
|
||||
className,
|
||||
isLoading,
|
||||
detailsResult,
|
||||
unityVersionsResult,
|
||||
requestChangeUnityVersion,
|
||||
}: {
|
||||
className?: string;
|
||||
isLoading: boolean | undefined;
|
||||
detailsResult: UseQueryResult<TauriProjectDetails, Error>;
|
||||
unityVersionsResult: UseQueryResult<TauriUnityVersions, Error>;
|
||||
requestChangeUnityVersion: (
|
||||
version: string,
|
||||
mayUseChinaVariant?: boolean,
|
||||
) => void;
|
||||
}) {
|
||||
const { projectPath } = Route.useSearch();
|
||||
const projectName = nameFromPath(projectPath);
|
||||
|
||||
return (
|
||||
<HNavBar
|
||||
className={className}
|
||||
leading={
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={"compact:h-10"}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
onClick={() => history.back()}
|
||||
>
|
||||
<ArrowLeft className={"w-5 h-5"} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tc("projects:manage:tooltip:back to projects")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className={"pl-2 space-y-0 shrink min-w-0 compact:pl-0"}>
|
||||
<p className="cursor-pointer font-bold grow-0 whitespace-pre mb-0 leading-tight">
|
||||
{projectName}
|
||||
</p>
|
||||
<p className="cursor-pointer text-sm leading-tight mt-0">
|
||||
{tc(
|
||||
"projects:manage:project location",
|
||||
{ path: projectPath },
|
||||
{
|
||||
components: {
|
||||
path: (
|
||||
<span
|
||||
className={
|
||||
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="cursor-pointer py-1.5 font-bold">
|
||||
{tc("projects:manage:unity version")}
|
||||
</p>
|
||||
<div className={"flex"}>
|
||||
<UnityVersionSelector
|
||||
disabled={isLoading}
|
||||
detailsResult={detailsResult}
|
||||
unityVersions={unityVersionsResult.data}
|
||||
requestChangeUnityVersion={requestChangeUnityVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"grow-0 shrink-0 w-max"}>
|
||||
<ProjectButton
|
||||
projectPath={projectPath}
|
||||
unityVersion={detailsResult.data?.unity_str ?? null}
|
||||
unityRevision={detailsResult.data?.unity_revision ?? null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchSettings({
|
||||
defaultUnityArgs,
|
||||
initialValue,
|
||||
dialog,
|
||||
}: {
|
||||
defaultUnityArgs: string[];
|
||||
initialValue: string[] | null;
|
||||
dialog: DialogContext<string[] | null | false>;
|
||||
}) {
|
||||
const context = useUnityArgumentsSettings(initialValue, defaultUnityArgs);
|
||||
|
||||
const saveAndClose = async () => {
|
||||
dialog.close(context.currentValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<div className={"max-h-[50dvh] overflow-y-auto"}>
|
||||
<h3 className={"text-lg"}>
|
||||
{tc("projects:dialog:command-line arguments")}
|
||||
</h3>
|
||||
<UnityArgumentsSettings context={context} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button onClick={saveAndClose} disabled={context.hasError}>
|
||||
{tc("general:button:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function projectGetUnityPath(projectPath: string) {
|
||||
return queryOptions({
|
||||
queryFn: () => commands.projectGetUnityPath(projectPath),
|
||||
queryKey: ["projectGetUnityPath", projectPath],
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function DropdownMenuContentBody({
|
||||
projectPath,
|
||||
removeProject,
|
||||
onChangeLaunchOptions,
|
||||
}: {
|
||||
projectPath: string;
|
||||
removeProject?: () => void;
|
||||
onChangeLaunchOptions?: () => void;
|
||||
}) {
|
||||
const openProjectFolder = () =>
|
||||
commands.utilOpen(projectPath, "ErrorIfNotExists");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const setUnityPath = useMutation({
|
||||
mutationFn: async (unityPath: string | null) =>
|
||||
await commands.projectSetUnityPath(projectPath, unityPath),
|
||||
onMutate: async (unityPath) => {
|
||||
const getUnityPath = projectGetUnityPath(projectPath);
|
||||
await queryClient.invalidateQueries(getUnityPath);
|
||||
const data = queryClient.getQueryData(getUnityPath.queryKey);
|
||||
queryClient.setQueryData(getUnityPath.queryKey, unityPath);
|
||||
return data;
|
||||
},
|
||||
onError: (e, _, data) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(projectGetUnityPath(projectPath).queryKey, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toastSuccess(tc("projects:toast:forgot unity path"));
|
||||
},
|
||||
});
|
||||
const unityPathQuery = useQuery(projectGetUnityPath(projectPath));
|
||||
|
||||
const navigate = useNavigate();
|
||||
const onCopyProject = async () => {
|
||||
try {
|
||||
await copyProject(projectPath, navigate);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onBackup = async () => {
|
||||
try {
|
||||
await openSingleDialog(BackupProjectDialog, {
|
||||
projectPath,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unityPath = unityPathQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onChangeLaunchOptions}>
|
||||
{tc("projects:menuitem:change launch options")}
|
||||
</DropdownMenuItem>
|
||||
{unityPath && (
|
||||
<DropdownMenuItem onClick={() => setUnityPath.mutate(null)}>
|
||||
{tc("projects:menuitem:forget unity path")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={openProjectFolder}>
|
||||
{tc("projects:menuitem:open directory")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onCopyProject}>
|
||||
{tc("projects:menuitem:copy project")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onBackup}>
|
||||
{tc("projects:menuitem:backup")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={removeProject}
|
||||
className={"text-destructive focus:text-destructive"}
|
||||
>
|
||||
{tc("projects:remove project")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectButton({
|
||||
projectPath,
|
||||
unityVersion,
|
||||
unityRevision,
|
||||
}: {
|
||||
projectPath: string;
|
||||
unityVersion: string | null;
|
||||
unityRevision: string | null;
|
||||
}) {
|
||||
const onChangeLaunchOptions = async () => {
|
||||
const initialArgs = await commands.projectGetCustomUnityArgs(projectPath);
|
||||
const defaultArgs = await commands.environmentGetDefaultUnityArguments();
|
||||
const settings = await openSingleDialog(LaunchSettings, {
|
||||
initialValue: initialArgs,
|
||||
defaultUnityArgs: defaultArgs,
|
||||
});
|
||||
if (settings === false) return;
|
||||
await commands.projectSetCustomUnityArgs(projectPath, settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className={"flex divide-x"}>
|
||||
<OpenUnityButton
|
||||
projectPath={projectPath}
|
||||
unityVersion={unityVersion}
|
||||
unityRevision={unityRevision}
|
||||
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
|
||||
/>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
|
||||
>
|
||||
<Button>
|
||||
<ChevronDown className={"w-4 h-4"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContentBody
|
||||
projectPath={projectPath}
|
||||
removeProject={() => {
|
||||
void openSingleDialog(RemoveProjectDialog, {
|
||||
project: {
|
||||
path: projectPath,
|
||||
is_exists: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChangeLaunchOptions={onChangeLaunchOptions}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
83
vrc-get-gui/app/_main/route.tsx
Normal file
83
vrc-get-gui/app/_main/route.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SideBar } from "@/components/SideBar";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useDocumentEvent } from "@/lib/events";
|
||||
import { updateCurrentPath, usePrevPathName } from "@/lib/prev-page";
|
||||
import { useEffectEvent } from "@/lib/use-effect-event";
|
||||
|
||||
export const Route = createFileRoute("/_main")({
|
||||
component: MainLayout,
|
||||
});
|
||||
|
||||
function MainLayout() {
|
||||
const [animationState, setAnimationState] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [guiAnimation, setGuiAnimation] = useState(false);
|
||||
const previousPathName = usePrevPathName();
|
||||
const pathName = useLocation().pathname;
|
||||
|
||||
useDocumentEvent(
|
||||
"gui-animation",
|
||||
(event) => {
|
||||
setGuiAnimation(event.detail);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPathChange = useEffectEvent((pathName: string) => {
|
||||
updateCurrentPath(pathName);
|
||||
|
||||
(async () => {
|
||||
setGuiAnimation(await commands.environmentGuiAnimation());
|
||||
})();
|
||||
|
||||
if (!guiAnimation) return;
|
||||
|
||||
if (pathName === previousPathName) return;
|
||||
const pageCategory = pathName.split("/")[1];
|
||||
const previousPageCategory = previousPathName.split("/")[1];
|
||||
if (pageCategory !== previousPageCategory) {
|
||||
// category change is always fade-in
|
||||
setAnimationState("fade-in");
|
||||
} else {
|
||||
// go deeper is slide-left, go back is slide-right, and no animation if not child-parent relation
|
||||
if (pathName.startsWith(previousPathName)) {
|
||||
setAnimationState("slide-left");
|
||||
} else if (previousPathName.startsWith(pathName)) {
|
||||
setAnimationState("slide-right");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onPathChange(pathName);
|
||||
}, [pathName]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (await commands.environmentGuiCompact()) {
|
||||
document.documentElement.setAttribute("compact", "");
|
||||
} else {
|
||||
document.documentElement.removeAttribute("compact");
|
||||
}
|
||||
setIsVisible(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideBar
|
||||
className={`grow-0 ${isVisible ? "slide-right" : "invisible"}`}
|
||||
/>
|
||||
<div
|
||||
className={`h-screen grow overflow-hidden flex p-4 compact:p-2 ${animationState}`}
|
||||
onAnimationEnd={() => setAnimationState("")}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
962
vrc-get-gui/app/_main/settings/index.tsx
Normal file
962
vrc-get-gui/app/_main/settings/index.tsx
Normal file
|
|
@ -0,0 +1,962 @@
|
|||
"use client";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Suspense, useEffect, useTransition } from "react";
|
||||
import Loading from "@/app/-loading";
|
||||
import { CheckForUpdateMessage } from "@/components/CheckForUpdateMessage";
|
||||
import {
|
||||
BackupFormatSelect,
|
||||
BackupPathWarnings,
|
||||
FilePathRow,
|
||||
GuiAnimationSwitch,
|
||||
GuiCompactSwitch,
|
||||
LanguageSelector,
|
||||
ProjectPathWarnings,
|
||||
ThemeSelector,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
|
||||
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
UnityArgumentsSettings,
|
||||
useUnityArgumentsSettings,
|
||||
} from "@/components/unity-arguments-settings";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import type { OpenOptions, UnityHubAccessMethod } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
|
||||
import globalInfo, { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import {
|
||||
toastError,
|
||||
toastNormal,
|
||||
toastSuccess,
|
||||
toastThrownError,
|
||||
} from "@/lib/toast";
|
||||
import { useEffectEvent } from "@/lib/use-effect-event";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/_main/settings/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
const environmentGetSettings = queryOptions({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
queryFn: commands.environmentGetSettings,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<VStack>
|
||||
<HNavBar
|
||||
className="shrink-0"
|
||||
leading={<HNavBarText>{tc("settings")}</HNavBarText>}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Card className={"p-4"}>
|
||||
<Loading loadingText={tc("general:loading...")} />
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const [updatingUnityPaths, updateUnityPathsTransition] = useTransition();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateUnityPaths = async () => {
|
||||
updateUnityPathsTransition(async () => {
|
||||
await commands.environmentUpdateUnityPathsFromUnityHub();
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
});
|
||||
};
|
||||
|
||||
// at the time settings page is opened, unity hub path update might be in progress so we wait for it
|
||||
const waitForHubUpdate = useEffectEvent(async () => {
|
||||
updateUnityPathsTransition(async () => {
|
||||
await commands.environmentWaitForUnityHubUpdate();
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
});
|
||||
});
|
||||
useEffect(() => void waitForHubUpdate(), []);
|
||||
|
||||
return (
|
||||
<ScrollPageContainer viewportClassName={"rounded-xl shadow-xl h-full"}>
|
||||
<main className="flex flex-col gap-2 shrink grow">
|
||||
<UnityHubPathCard updateUnityPaths={updateUnityPaths} />
|
||||
<UnityInstallationsCard
|
||||
updatingUnityPaths={updatingUnityPaths}
|
||||
updateUnityPaths={updateUnityPaths}
|
||||
/>
|
||||
<UnityLaunchArgumentsCard />
|
||||
<DefaultProjectPathCard />
|
||||
<BackupCard />
|
||||
<PackagesCard />
|
||||
<AppearanceCard />
|
||||
<FilesAndFoldersCard />
|
||||
<AlcomCard />
|
||||
<SystemInformationCard />
|
||||
</main>
|
||||
</ScrollPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCard({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Card>) {
|
||||
return (
|
||||
<Card className={cn("shrink-0 p-4 compact:p-3", className)} {...props}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityHubPathCard({
|
||||
updateUnityPaths,
|
||||
}: {
|
||||
updateUnityPaths: () => Promise<void>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: { unityHub },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
unityHub: data.unity_hub,
|
||||
}),
|
||||
});
|
||||
|
||||
const pickUnityHub = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickUnityHub(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:unity hub path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
await updateUnityPaths();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<h2 className={"pb-2"}>{tc("settings:unity hub path")}</h2>
|
||||
<FilePathRow
|
||||
path={unityHub}
|
||||
pick={pickUnityHub.mutate}
|
||||
notFoundMessage={"Unity Hub Not Found"}
|
||||
withOpen={false}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityInstallationsCard({
|
||||
updatingUnityPaths,
|
||||
updateUnityPaths,
|
||||
}: {
|
||||
updatingUnityPaths: boolean;
|
||||
updateUnityPaths: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data: { unityPaths, unityHubAccessMethod },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
unityPaths: data.unity_paths,
|
||||
unityHubAccessMethod: data.unity_hub_access_method,
|
||||
}),
|
||||
});
|
||||
|
||||
const addUnity = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickUnity(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tt("settings:toast:not unity"));
|
||||
break;
|
||||
case "AlreadyAdded":
|
||||
toastError(tt("settings:toast:unity already added"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tt("settings:toast:unity added"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
const setAccessMethod = useMutation({
|
||||
mutationFn: async (method: UnityHubAccessMethod) =>
|
||||
await commands.environmentSetUnityHubAccessMethod(method),
|
||||
onMutate: async (method) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
unity_hub_access_method: method,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const UNITY_TABLE_HEAD = [
|
||||
"settings:unity:version",
|
||||
"settings:unity:path",
|
||||
"general:source",
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsCard className={"flex flex-col gap-2"}>
|
||||
<div className={"flex align-middle"}>
|
||||
<div className={"grow flex items-center"}>
|
||||
<h2>{tc("settings:unity installations")}</h2>
|
||||
</div>
|
||||
{updatingUnityPaths && (
|
||||
<div className={"flex items-center m-1"}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tc("settings:tooltip:reload unity from unity hub")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
disabled={updatingUnityPaths}
|
||||
onClick={updateUnityPaths}
|
||||
size={"sm"}
|
||||
className={"m-1"}
|
||||
>
|
||||
{tc("settings:button:reload unity from unity hub")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tc("settings:tooltip:reload unity from unity hub")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={updatingUnityPaths}
|
||||
onClick={() => addUnity.mutate()}
|
||||
size={"sm"}
|
||||
className={"m-1"}
|
||||
>
|
||||
{tc("settings:button:add unity")}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollableCardTable
|
||||
className={`w-full min-h-[20vh] ${updatingUnityPaths ? "opacity-50" : ""}`}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{UNITY_TABLE_HEAD.map((head, index) => (
|
||||
<th
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array
|
||||
key={index}
|
||||
className={
|
||||
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
|
||||
}
|
||||
>
|
||||
<small className="font-normal leading-none">{tc(head)}</small>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unityPaths.map(([path, version, isFromHub]) => (
|
||||
<tr key={path} className="even:bg-secondary/30">
|
||||
<td className={"p-2.5"}>{version}</td>
|
||||
<td className={"p-2.5"}>{path}</td>
|
||||
<td className={"p-2.5"}>
|
||||
{isFromHub
|
||||
? tc("settings:unity:source:unity hub")
|
||||
: tc("settings:unity:source:manual")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</ScrollableCardTable>
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={unityHubAccessMethod === "CallHub"}
|
||||
onCheckedChange={(e) =>
|
||||
setAccessMethod.mutate(e === true ? "CallHub" : "ReadConfig")
|
||||
}
|
||||
/>
|
||||
{tc("settings:use legacy unity hub loading")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:use legacy unity hub loading description")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function UnityLaunchArgumentsCard() {
|
||||
const { data: unityArgs } = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (d) => d.default_unity_arguments,
|
||||
});
|
||||
|
||||
const defaultUnityArgs = useGlobalInfo().defaultUnityArguments;
|
||||
const realUnityArgs = unityArgs ?? defaultUnityArgs;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<div className={"mb-2 flex align-middle"}>
|
||||
<div className={"grow flex items-center"}>
|
||||
<h2>{tc("settings:default unity arguments")}</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openSingleDialog(LaunchArgumentsEditDialogBody, {
|
||||
unityArgs,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
}}
|
||||
size={"sm"}
|
||||
className={"m-1"}
|
||||
>
|
||||
{tc("general:button:edit")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("settings:default unity arguments description")}
|
||||
</p>
|
||||
<ol className={"flex flex-col"}>
|
||||
{realUnityArgs.map((v, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: unity args are ordered list
|
||||
<Input disabled key={i + v} value={v} className={"w-full"} />
|
||||
))}
|
||||
</ol>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchArgumentsEditDialogBody({
|
||||
unityArgs,
|
||||
dialog,
|
||||
}: {
|
||||
unityArgs: string[] | null;
|
||||
dialog: DialogContext<boolean>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setDefaultArgs = useMutation({
|
||||
mutationFn: async ({ value }: { value: string[] | null }) => {
|
||||
return await commands.environmentSetDefaultUnityArguments(value);
|
||||
},
|
||||
onMutate: async ({ value }) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
default_unity_arguments: value,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
dialog.error(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialog.close(true);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const context = useUnityArgumentsSettings(
|
||||
unityArgs,
|
||||
globalInfo.defaultUnityArguments,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{tc("settings:dialog:default launch arguments")}
|
||||
</DialogTitle>
|
||||
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
|
||||
<div className={"max-h-[50dvh] overflow-y-auto"}>
|
||||
<UnityArgumentsSettings context={context} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
|
||||
{tc("general:button:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
void setDefaultArgs.mutate({ value: context.currentValue })
|
||||
}
|
||||
disabled={context.hasError}
|
||||
>
|
||||
{tc("general:button:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultProjectPathCard() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: { defaultProjectPath },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
defaultProjectPath: data.default_project_path,
|
||||
}),
|
||||
});
|
||||
|
||||
const pickProjectDefaultPath = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickProjectDefaultPath(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:default project path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<h2 className={"mb-2"}>{tc("settings:default project path")}</h2>
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc("settings:default project path description")}
|
||||
</p>
|
||||
<FilePathRow
|
||||
path={defaultProjectPath}
|
||||
pick={pickProjectDefaultPath.mutate}
|
||||
/>
|
||||
<ProjectPathWarnings projectPath={defaultProjectPath} />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupCard() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: { projectBackupPath, backupFormat, excludeVpmPackagesFromBackup },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
projectBackupPath: data.project_backup_path,
|
||||
backupFormat: data.backup_format,
|
||||
excludeVpmPackagesFromBackup: data.exclude_vpm_packages_from_backup,
|
||||
}),
|
||||
});
|
||||
|
||||
const pickProjectBackupPath = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickProjectBackupPath(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:backup path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const setExcludeVpmPackagesFromBackup = useMutation({
|
||||
mutationFn: async (flag: boolean) =>
|
||||
await commands.environmentSetExcludeVpmPackagesFromBackup(flag),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (_, flag) => {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, (old) => {
|
||||
if (old == null) return old;
|
||||
return { ...old, excludeVpmPackagesFromBackup: flag };
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<h2>{tc("projects:backup")}</h2>
|
||||
<div className="mt-2">
|
||||
<h3>{tc("settings:backup:path")}</h3>
|
||||
<p className={"whitespace-normal text-sm"}>
|
||||
{tc("settings:backup:path description")}
|
||||
</p>
|
||||
<FilePathRow
|
||||
path={projectBackupPath}
|
||||
pick={pickProjectBackupPath.mutate}
|
||||
/>
|
||||
<BackupPathWarnings backupPath={projectBackupPath} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3>{tc("settings:backup:format")}</h3>
|
||||
<p className={"whitespace-normal text-sm"}>
|
||||
{tc("settings:backup:format description")}
|
||||
</p>
|
||||
<label className={"flex items-center"}>
|
||||
<BackupFormatSelect backupFormat={backupFormat} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={excludeVpmPackagesFromBackup}
|
||||
onCheckedChange={(e) =>
|
||||
setExcludeVpmPackagesFromBackup.mutate(e === true)
|
||||
}
|
||||
/>
|
||||
{tc("settings:backup:exclude vpm packages from backup")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:backup:exclude vpm packages from backup description")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PackagesCard() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: { showPrereleasePackages },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
showPrereleasePackages: data.show_prerelease_packages,
|
||||
}),
|
||||
});
|
||||
|
||||
const clearPackageCache = useMutation({
|
||||
mutationFn: async () => await commands.environmentClearPackageCache(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toastSuccess(tc("settings:toast:package cache cleared"));
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentPackages"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const setShowPrerelease = useMutation({
|
||||
mutationFn: async (showPrerelease: boolean) =>
|
||||
await commands.environmentSetShowPrereleasePackages(showPrerelease),
|
||||
onMutate: async (showPrerelease) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
show_prerelease_packages: showPrerelease,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard className={"flex flex-col gap-4"}>
|
||||
<h2>{tc("settings:packages")}</h2>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button onClick={() => clearPackageCache.mutate()}>
|
||||
{tc("settings:clear package cache")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={showPrereleasePackages}
|
||||
onCheckedChange={(e) => setShowPrerelease.mutate(e === true)}
|
||||
/>
|
||||
{tc("settings:show prerelease")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:show prerelease description")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceCard() {
|
||||
return (
|
||||
<SettingsCard className={"flex flex-col gap-2"}>
|
||||
<h2>{tc("settings:appearance")}</h2>
|
||||
<LanguageSelector />
|
||||
<ThemeSelector />
|
||||
<GuiAnimationSwitch />
|
||||
<GuiCompactSwitch />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesAndFoldersCard() {
|
||||
const openVpmFolderContent = (
|
||||
subPath: string,
|
||||
ifNotExists: OpenOptions = "ErrorIfNotExists",
|
||||
) => {
|
||||
return async () => {
|
||||
try {
|
||||
await commands.utilOpen(
|
||||
`${globalInfo.vpmHomeFolder}/${subPath}`,
|
||||
ifNotExists,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<h2>{tc("settings:files and directories")}</h2>
|
||||
<p className={"mt-2"}>
|
||||
{tc("settings:files and directories:description")}
|
||||
</p>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button
|
||||
className={"normal-case"}
|
||||
onClick={openVpmFolderContent("settings.json")}
|
||||
>
|
||||
{tc("settings:button:open settings.json")}
|
||||
</Button>
|
||||
<Button
|
||||
className={"normal-case"}
|
||||
onClick={openVpmFolderContent("vrc-get/gui-config.json")}
|
||||
>
|
||||
{tc("settings:button:open gui config.json")}
|
||||
</Button>
|
||||
<Button onClick={openVpmFolderContent("vrc-get/gui-logs")}>
|
||||
{tc("settings:button:open logs")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={openVpmFolderContent("Templates", "CreateFolderIfNotExists")}
|
||||
>
|
||||
{tc("settings:button:open vcc templates")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AlcomCard() {
|
||||
const globalInfo = useGlobalInfo();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: { releaseChannel, useAlcomForVccProtocol },
|
||||
} = useSuspenseQuery({
|
||||
...environmentGetSettings,
|
||||
select: (data) => ({
|
||||
releaseChannel: data.release_channel,
|
||||
useAlcomForVccProtocol: data.use_alcom_for_vcc_protocol,
|
||||
}),
|
||||
});
|
||||
|
||||
const setShowPrerelease = useMutation({
|
||||
mutationFn: async (releaseChannel: string) =>
|
||||
await commands.environmentSetReleaseChannel(releaseChannel),
|
||||
onMutate: async (releaseChannel) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
release_channel: releaseChannel,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const setUseAlcomForVccProtocol = useMutation({
|
||||
mutationFn: async (use: boolean) =>
|
||||
await commands.environmentSetUseAlcomForVccProtocol(use),
|
||||
onMutate: async (use) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
use_alcom_for_vcc_protocol: use,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const installVccProtocol = useMutation({
|
||||
mutationFn: async () => await commands.deepLinkInstallVcc(),
|
||||
onSuccess: () => {
|
||||
toastSuccess(tc("settings:toast:vcc scheme installed"));
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
try {
|
||||
const checkVersion = await commands.utilCheckForUpdate();
|
||||
if (checkVersion) {
|
||||
await openSingleDialog(CheckForUpdateMessage, {
|
||||
response: checkVersion,
|
||||
});
|
||||
} else {
|
||||
toastNormal(tc("check update:toast:no updates"));
|
||||
}
|
||||
} catch (e) {
|
||||
toastThrownError(e);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const reportIssue = async () => {
|
||||
const url = new URL("https://github.com/vrc-get/vrc-get/issues/new");
|
||||
url.searchParams.append("labels", "bug,vrc-get-gui");
|
||||
url.searchParams.append("template", "01_gui_bug-report.yml");
|
||||
url.searchParams.append("os", `${globalInfo.osInfo} - ${globalInfo.arch}`);
|
||||
url.searchParams.append("webview-version", `${globalInfo.webviewVersion}`);
|
||||
let version = globalInfo.version ?? "unknown";
|
||||
if (globalInfo.commitHash) {
|
||||
version += ` (${globalInfo.commitHash})`;
|
||||
} else {
|
||||
version += " (unknown commit)";
|
||||
}
|
||||
url.searchParams.append("version", version);
|
||||
|
||||
void commands.utilOpenUrl(url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard className={"flex flex-col gap-4"}>
|
||||
<h2>ALCOM</h2>
|
||||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
{globalInfo.checkForUpdates && (
|
||||
<Button onClick={checkForUpdate}>
|
||||
{tc("settings:check update")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={reportIssue}>
|
||||
{tc("settings:button:open issue")}
|
||||
</Button>
|
||||
</div>
|
||||
{globalInfo.checkForUpdates && (
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={releaseChannel === "beta"}
|
||||
onCheckedChange={(value) =>
|
||||
setShowPrerelease.mutate(value === true ? "beta" : "stable")
|
||||
}
|
||||
/>
|
||||
{tc("settings:receive beta updates")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc("settings:beta updates description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{globalInfo.shouldInstallDeepLink && (
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={useAlcomForVccProtocol}
|
||||
onCheckedChange={(value) =>
|
||||
setUseAlcomForVccProtocol.mutate(value === true)
|
||||
}
|
||||
/>
|
||||
{tc("settings:use alcom for vcc scheme")}
|
||||
</label>
|
||||
<Button
|
||||
className={"my-1"}
|
||||
disabled={!useAlcomForVccProtocol}
|
||||
onClick={() => installVccProtocol.mutate()}
|
||||
>
|
||||
{tc("settings:register vcc scheme now")}
|
||||
</Button>
|
||||
<p className={"text-sm whitespace-normal"}>
|
||||
{tc([
|
||||
"settings:use vcc scheme description",
|
||||
"settings:vcc scheme description",
|
||||
])}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className={"whitespace-normal"}>
|
||||
{tc(
|
||||
"settings:licenses description",
|
||||
{},
|
||||
{
|
||||
components: {
|
||||
l: <Link to={"/settings/licenses"} className={"underline"} />,
|
||||
},
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemInformationCard() {
|
||||
const info = useGlobalInfo();
|
||||
|
||||
return (
|
||||
<SettingsCard className={"flex flex-col gap-4"}>
|
||||
<h2>{tc("settings:system information")}</h2>
|
||||
<dl>
|
||||
<dt>{tc("settings:os")}</dt>
|
||||
<dd className={"opacity-50 mb-2"}>{info.osInfo}</dd>
|
||||
<dt>{tc("settings:architecture")}</dt>
|
||||
<dd className={"opacity-50 mb-2"}>{info.arch}</dd>
|
||||
<dt>{tc("settings:webview version")}</dt>
|
||||
<dd className={"opacity-50 mb-2"}>{info.webviewVersion}</dd>
|
||||
<dt>{tc("settings:alcom version")}</dt>
|
||||
<dd className={"opacity-50 mb-2"}>{info.version}</dd>
|
||||
<dt>{tc("settings:alcom commit hash")}</dt>
|
||||
<dd className={"opacity-50 mb-2"}>{info.commitHash}</dd>
|
||||
</dl>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { ScrollableCard } from "@/components/ScrollableCard";
|
||||
import licenses from "build:licenses.json";
|
||||
import { VStack } from "@/components/layout";
|
||||
import { ScrollableCard } from "@/components/ScrollableCard";
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import type { Licenses } from "@/lib/licenses";
|
||||
|
||||
export default function RenderPage({
|
||||
licenses,
|
||||
}: { licenses: Licenses | null }) {
|
||||
if (licenses === null) {
|
||||
return (
|
||||
<div className={"whitespace-normal"}>
|
||||
<p>Failed to load licenses.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RenderPage() {
|
||||
return (
|
||||
<ScrollPageContainer>
|
||||
<VStack>
|
||||
|
|
@ -30,7 +20,7 @@ export default function RenderPage({
|
|||
<ul />
|
||||
</Card>
|
||||
|
||||
{licenses.map((license, idx) => (
|
||||
{licenses.map((license) => (
|
||||
<Card className={"p-4"} key={license.text}>
|
||||
<h3>{license.name}</h3>
|
||||
<h4>Used by:</h4>
|
||||
50
vrc-get-gui/app/_main/settings/licenses/index.tsx
Normal file
50
vrc-get-gui/app/_main/settings/licenses/index.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import licenses from "build:licenses.json";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { VStack } from "@/components/layout";
|
||||
import { ScrollableCard } from "@/components/ScrollableCard";
|
||||
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { commands } from "@/lib/bindings";
|
||||
|
||||
export const Route = createFileRoute("/_main/settings/licenses/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<ScrollPageContainer>
|
||||
<VStack>
|
||||
<Card className={"p-4"}>
|
||||
<p>
|
||||
This project is built on top of many open-source projects.
|
||||
<br />
|
||||
Here are the licenses of the projects used in this project:
|
||||
</p>
|
||||
<ul />
|
||||
</Card>
|
||||
|
||||
{licenses.map((license) => (
|
||||
<Card className={"p-4"} key={license.text}>
|
||||
<h3>{license.name}</h3>
|
||||
<h4>Used by:</h4>
|
||||
<ul className={"ml-2"}>
|
||||
{license.packages.map((pkg) => (
|
||||
<li key={`${pkg.name}@${pkg.version}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commands.utilOpenUrl(pkg.url)}
|
||||
>
|
||||
{pkg.name} ({pkg.version})
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ScrollableCard className="max-h-52">
|
||||
<pre className={"whitespace-pre-wrap"}>{license.text}</pre>
|
||||
</ScrollableCard>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</ScrollPageContainer>
|
||||
);
|
||||
}
|
||||
44
vrc-get-gui/app/_setup/route.tsx
Normal file
44
vrc-get-gui/app/_setup/route.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_setup")({
|
||||
component: SetupLayout,
|
||||
});
|
||||
|
||||
function SetupLayout() {
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"h-screen grow overflow-hidden flex p-4"}>
|
||||
<Outlet />
|
||||
</div>
|
||||
{isDev && <DevTools />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTools() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={"absolute bottom-0 left-0 p-4 flex flex-col gap-3"}>
|
||||
<p>debug tools</p>
|
||||
<div className={"flex gap-3"}>
|
||||
<button type="button" onClick={() => router.history.back()}>
|
||||
Go Back
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate({ to: "/settings" })}>
|
||||
Go Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import type { SetupPages, TauriEnvironmentSettings } from "@/lib/bindings";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type React from "react";
|
||||
|
||||
export type BodyProps = Readonly<{
|
||||
environment: TauriEnvironmentSettings;
|
||||
refetch: () => void;
|
||||
}>;
|
||||
|
||||
export function SetupPageBase({
|
||||
|
|
@ -19,6 +18,7 @@ export function SetupPageBase({
|
|||
Body,
|
||||
nextPage,
|
||||
prevPage,
|
||||
onFinish,
|
||||
backContent = tc("setup:back"),
|
||||
nextContent = tc("setup:next"),
|
||||
pageId,
|
||||
|
|
@ -28,12 +28,13 @@ export function SetupPageBase({
|
|||
Body: React.ComponentType<BodyProps>;
|
||||
nextPage: string;
|
||||
prevPage: string | null;
|
||||
onFinish?: () => void;
|
||||
backContent?: React.ReactNode;
|
||||
nextContent?: React.ReactNode;
|
||||
pageId: SetupPages | null;
|
||||
withoutSteps?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
|
|
@ -42,7 +43,8 @@ export function SetupPageBase({
|
|||
|
||||
const onNext = async () => {
|
||||
if (pageId) await commands.environmentFinishedSetupPage(pageId);
|
||||
router.push(nextPage);
|
||||
navigate({ to: nextPage });
|
||||
onFinish?.();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -50,9 +52,9 @@ export function SetupPageBase({
|
|||
<div className={"flex gap-4"}>
|
||||
{!withoutSteps && <StepCard current={pageId} />}
|
||||
<Card
|
||||
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3`}
|
||||
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3 compact:min-h-[max(40dvh,20rem)]`}
|
||||
>
|
||||
<div className={"flex flex-col flex-grow"}>
|
||||
<div className={"flex flex-col grow gap-3 compact:gap-2"}>
|
||||
<CardHeader>
|
||||
<h1 className={"text-center"}>{heading}</h1>
|
||||
</CardHeader>
|
||||
|
|
@ -60,15 +62,12 @@ export function SetupPageBase({
|
|||
{!result.data ? (
|
||||
<p>{tc("setup:loading")}</p>
|
||||
) : (
|
||||
<Body
|
||||
environment={result.data}
|
||||
refetch={() => result.refetch()}
|
||||
/>
|
||||
<Body environment={result.data} />
|
||||
)}
|
||||
<div className={"flex-grow"} />
|
||||
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end">
|
||||
<div className={"grow"} />
|
||||
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end compact:-m-2">
|
||||
{prevPage && (
|
||||
<Button onClick={() => router.push(prevPage)}>
|
||||
<Button onClick={() => navigate({ to: prevPage })}>
|
||||
{backContent}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -81,11 +80,7 @@ export function SetupPageBase({
|
|||
);
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
current,
|
||||
}: {
|
||||
current: SetupPages | null;
|
||||
}) {
|
||||
function StepCard({ current }: { current: SetupPages | null }) {
|
||||
// TODO: get progress from backend
|
||||
const finisheds = useQuery({
|
||||
queryKey: ["environmentGetFinishedSetupPages"],
|
||||
|
|
@ -93,7 +88,7 @@ function StepCard({
|
|||
initialData: [],
|
||||
}).data;
|
||||
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
|
||||
|
||||
return (
|
||||
<Card className={"w-48 p-4"}>
|
||||
|
|
@ -118,7 +113,7 @@ function StepCard({
|
|||
finisheds={finisheds}
|
||||
pageId={"Backups"}
|
||||
/>
|
||||
{!isMac && (
|
||||
{shouldInstallDeepLink && (
|
||||
<StepElement
|
||||
current={current}
|
||||
finisheds={finisheds}
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
GuiAnimationSwitch,
|
||||
GuiCompactSwitch,
|
||||
LanguageSelector,
|
||||
ThemeSelector,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { SetupPageBase } from "../setup-page-base";
|
||||
import { SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
export const Route = createFileRoute("/_setup/setup/appearance/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:entry:welcome")}
|
||||
|
|
@ -33,6 +40,8 @@ function Body() {
|
|||
</CardDescription>
|
||||
<LanguageSelector />
|
||||
<ThemeSelector />
|
||||
<GuiAnimationSwitch />
|
||||
<GuiCompactSwitch />
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
vrc-get-gui/app/_setup/setup/backups/index.tsx
Normal file
92
vrc-get-gui/app/_setup/setup/backups/index.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
BackupFormatSelect,
|
||||
BackupPathWarnings,
|
||||
FilePathRow,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export const Route = createFileRoute("/_setup/setup/backups/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
|
||||
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:backups:heading")}
|
||||
Body={Body}
|
||||
nextPage={
|
||||
shouldInstallDeepLink ? "/setup/system-setting" : "/setup/finish"
|
||||
}
|
||||
prevPage={"/setup/project-path"}
|
||||
pageId={"Backups"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ environment }: BodyProps) {
|
||||
const projectBackupPath = environment.project_backup_path;
|
||||
const backupFormat = environment.backup_format;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const pickProjectBackupPath = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickProjectBackupPath(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:backup path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{tc("setup:backups:location")}</h3>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("setup:backups:location description")}
|
||||
</CardDescription>
|
||||
<FilePathRow
|
||||
path={projectBackupPath}
|
||||
pick={pickProjectBackupPath.mutate}
|
||||
withOpen={false}
|
||||
/>
|
||||
<BackupPathWarnings backupPath={projectBackupPath} />
|
||||
<div className={"pb-3"} />
|
||||
<h3>{tc("setup:backups:archive")}</h3>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("settings:backup:format description")}
|
||||
</CardDescription>
|
||||
<BackupFormatSelect backupFormat={backupFormat} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { SetupPageBase } from "../setup-page-base";
|
||||
import { SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
export const Route = createFileRoute("/_setup/setup/finish/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
|
||||
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:finish:heading")}
|
||||
Body={Body}
|
||||
nextPage={"/projects"}
|
||||
prevPage={isMac ? "/setup/backups" : "/setup/system-setting"}
|
||||
prevPage={
|
||||
shouldInstallDeepLink ? "/setup/system-setting" : "/setup/backups"
|
||||
}
|
||||
nextContent={tc("setup:finish:next")}
|
||||
pageId={null}
|
||||
/>
|
||||
76
vrc-get-gui/app/_setup/setup/project-path/index.tsx
Normal file
76
vrc-get-gui/app/_setup/setup/project-path/index.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
FilePathRow,
|
||||
ProjectPathWarnings,
|
||||
} from "@/components/common-setting-parts";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export const Route = createFileRoute("/_setup/setup/project-path/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:project-path:heading")}
|
||||
Body={Body}
|
||||
nextPage={"/setup/backups"}
|
||||
prevPage={"/setup/unity-hub"}
|
||||
pageId={"ProjectPath"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ environment }: BodyProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const pickProjectDefaultPath = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickProjectDefaultPath(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:default project path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
{tc("setup:project-path:description")}
|
||||
</CardDescription>
|
||||
<FilePathRow
|
||||
path={environment.default_project_path}
|
||||
pick={pickProjectDefaultPath.mutate}
|
||||
withOpen={false}
|
||||
/>
|
||||
<ProjectPathWarnings projectPath={environment.default_project_path} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
vrc-get-gui/app/_setup/setup/system-setting/index.tsx
Normal file
107
vrc-get-gui/app/_setup/setup/system-setting/index.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { useGlobalInfo } from "@/lib/global-info";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { toastThrownError } from "@/lib/toast";
|
||||
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export const Route = createFileRoute("/_setup/setup/system-setting/")({
|
||||
component: Page,
|
||||
});
|
||||
function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:system-setting:heading")}
|
||||
Body={Body}
|
||||
nextPage={"/setup/finish"}
|
||||
prevPage={"/setup/backups"}
|
||||
pageId={"SystemSetting"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const environmentGetSettings = queryOptions({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
queryFn: commands.environmentGetSettings,
|
||||
});
|
||||
|
||||
function Body({ environment }: BodyProps) {
|
||||
const useAlcomForVccProtocol = environment.use_alcom_for_vcc_protocol;
|
||||
|
||||
const isBadHostName = useQuery({
|
||||
queryKey: ["util_is_bad_hostname"],
|
||||
queryFn: commands.utilIsBadHostname,
|
||||
initialData: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setUseAlcomForVccProtocol = useMutation({
|
||||
mutationFn: async (use: boolean) =>
|
||||
await commands.environmentSetUseAlcomForVccProtocol(use),
|
||||
onMutate: async (use) => {
|
||||
await queryClient.cancelQueries(environmentGetSettings);
|
||||
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
|
||||
if (current != null) {
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, {
|
||||
...current,
|
||||
use_alcom_for_vcc_protocol: use,
|
||||
});
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onError: (e, _, prev) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(environmentGetSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const isMac = useGlobalInfo().osType === "Darwin";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMac ? (
|
||||
<div>
|
||||
<label className={"flex items-center gap-2"}>
|
||||
<Checkbox
|
||||
checked={useAlcomForVccProtocol}
|
||||
onCheckedChange={(e) =>
|
||||
setUseAlcomForVccProtocol.mutate(e === true)
|
||||
}
|
||||
/>
|
||||
{tc("settings:use alcom for vcc scheme")}
|
||||
</label>
|
||||
<p className={"text-sm whitespace-normal text-muted-foreground"}>
|
||||
{tc("setup:system-setting:vcc scheme description")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className={"text-sm whitespace-normal text-muted-foreground"}>
|
||||
{tc("setup:system-setting:macos bug message")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isBadHostName.data && (
|
||||
<div className={"mt-3"}>
|
||||
<p className={"text-sm whitespace-normal text-warning"}>
|
||||
{tc("setup:system-setting:hostname-with-non-ascii")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FilePathRow } from "@/components/common-setting-parts";
|
||||
import {
|
||||
Accordion,
|
||||
|
|
@ -9,15 +11,23 @@ import {
|
|||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardDescription } from "@/components/ui/card";
|
||||
import { assertNever } from "@/lib/assert-never";
|
||||
import { commands } from "@/lib/bindings";
|
||||
import { tc } from "@/lib/i18n";
|
||||
import { type BodyProps, SetupPageBase } from "../setup-page-base";
|
||||
import { tc, tt } from "@/lib/i18n";
|
||||
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
|
||||
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
|
||||
|
||||
export default function Page() {
|
||||
export const Route = createFileRoute("/_setup/setup/unity-hub/")({
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<SetupPageBase
|
||||
heading={tc("setup:unity-hub:heading")}
|
||||
Body={Body}
|
||||
// user should set unity hub path so we re-update unity paths
|
||||
onFinish={() => commands.environmentUpdateUnityPathsFromUnityHub()}
|
||||
nextPage={"/setup/project-path"}
|
||||
prevPage={"/setup/appearance"}
|
||||
pageId={"UnityHub"}
|
||||
|
|
@ -25,9 +35,39 @@ export default function Page() {
|
|||
);
|
||||
}
|
||||
|
||||
function Body({ environment, refetch }: BodyProps) {
|
||||
function Body({ environment }: BodyProps) {
|
||||
const hubInstalled = !!environment.unity_hub;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const pickUnityHub = useMutation({
|
||||
mutationFn: async () => await commands.environmentPickUnityHub(),
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toastThrownError(e);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
switch (result.type) {
|
||||
case "NoFolderSelected":
|
||||
// no-op
|
||||
break;
|
||||
case "InvalidSelection":
|
||||
toastError(tc("general:toast:invalid directory"));
|
||||
break;
|
||||
case "Successful":
|
||||
toastSuccess(tc("settings:toast:unity hub path updated"));
|
||||
break;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardDescription className={"whitespace-normal"}>
|
||||
|
|
@ -40,12 +80,10 @@ function Body({ environment, refetch }: BodyProps) {
|
|||
{tc("setup:unity-hub:using this unity hub")}:
|
||||
</p>
|
||||
<FilePathRow
|
||||
withoutSelect
|
||||
path={environment.unity_hub ?? ""}
|
||||
pick={commands.environmentPickUnityHub}
|
||||
refetch={refetch}
|
||||
pick={pickUnityHub.mutate}
|
||||
notFoundMessage={"Unity Hub Not Found"}
|
||||
successMessage={tc("settings:toast:unity hub path updated")}
|
||||
withOpen={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -54,12 +92,18 @@ function Body({ environment, refetch }: BodyProps) {
|
|||
<div className={"flex flex-row flex-wrap gap-2"}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
commands.utilOpenUrl("https://unity.com/ja/download")
|
||||
commands.utilOpenUrl(tt("setup:unity-hub:unity hub link"))
|
||||
}
|
||||
>
|
||||
{tc("setup:unity-hub:download unity hub from unity.com")}
|
||||
</Button>
|
||||
<Button onClick={refetch}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["environmentGetSettings"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{tc("setup:unity-hub:recheck installation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -73,12 +117,10 @@ function Body({ environment, refetch }: BodyProps) {
|
|||
{tc("setup:unity-hub:detection failed description")}
|
||||
</p>
|
||||
<FilePathRow
|
||||
withoutSelect
|
||||
path={environment.unity_hub}
|
||||
pick={commands.environmentPickUnityHub}
|
||||
refetch={refetch}
|
||||
path={environment.unity_hub ?? ""}
|
||||
pick={pickUnityHub.mutate}
|
||||
notFoundMessage={"Unity Hub Not Found"}
|
||||
successMessage={tc("settings:toast:unity hub path updated")}
|
||||
withOpen={false}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
|
@ -1,48 +1,166 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--background-start: 190 7.89% 85.1%;
|
||||
--background-end: 0, 0%, 100%;
|
||||
--foreground: 240 10% 20%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 35%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 20%;
|
||||
--primary: 240 5.9% 20%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 30%;
|
||||
--info: 207 90% 54%;
|
||||
--info-foreground: 210 40% 98%;
|
||||
--success: 122 39% 49%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
--warning: 52.15, 100%, 46.47%;
|
||||
--warning-foreground: 210 40% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.75rem;
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant compact (&:is([compact] *));
|
||||
|
||||
@theme inline {
|
||||
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
|
||||
--background-image-gradient-conic: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
var(--tw-gradient-stops)
|
||||
);
|
||||
|
||||
--font-sans: system-ui;
|
||||
--font-path: system-ui;
|
||||
--font-mono: consolas, monospace;
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-color: 240 10% 13%;
|
||||
--fg-color: 240 10% 85%;
|
||||
--secondary-bg: 240 3.7% 19%;
|
||||
--primary-fg: 240 5.9% 15%;
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.anchor-none {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--background-start: hsl(190 7.89% 85.1%);
|
||||
--background-end: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(240 10% 20%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 35%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 20%);
|
||||
--primary: hsl(240 5.9% 20%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 30%);
|
||||
--info: hsl(207 90% 54%);
|
||||
--info-foreground: hsl(210 40% 98%);
|
||||
--success: hsl(122 39% 49%);
|
||||
--success-foreground: hsl(210 40% 98%);
|
||||
--warning: hsl(52.15, 100%, 46.47%);
|
||||
--warning-foreground: hsl(240 10% 13%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 5.9% 10%);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-color: hsl(240 10% 13%);
|
||||
--fg-color: hsl(240 10% 85%);
|
||||
--secondary-bg: hsl(240 3.7% 19%);
|
||||
--primary-fg: hsl(240 5.9% 15%);
|
||||
|
||||
--background: var(--bg-color);
|
||||
--background-start: hsl(0 0% 3%);
|
||||
--background-end: hsl(10 8% 15%);
|
||||
--foreground: var(--fg-color);
|
||||
--card: var(--bg-color);
|
||||
--card-foreground: var(--fg-color);
|
||||
--popover: var(--bg-color);
|
||||
--popover-foreground: var(--fg-color);
|
||||
--primary: var(--fg-color);
|
||||
--primary-foreground: var(--primary-fg);
|
||||
--secondary: var(--secondary-bg);
|
||||
--secondary-foreground: var(--fg-color);
|
||||
--muted: var(--secondary-bg);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: var(--secondary-bg);
|
||||
--accent-foreground: var(--fg-color);
|
||||
--info: hsl(207 90% 54%);
|
||||
--info-foreground: hsl(210 40% 90%);
|
||||
--success: hsl(122 39% 49%);
|
||||
--success-foreground: hsl(210 40% 90%);
|
||||
--warning: hsl(52.15, 100%, 46.47%);
|
||||
--warning-foreground: hsl(240 10% 13%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: var(--fg-color);
|
||||
--border: var(--secondary-bg);
|
||||
--input: var(--secondary-bg);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.system {
|
||||
--bg-color: hsl(240 10% 13%);
|
||||
--fg-color: hsl(240 10% 85%);
|
||||
--secondary-bg: hsl(240 3.7% 19%);
|
||||
--primary-fg: hsl(240 5.9% 15%);
|
||||
|
||||
--background: var(--bg-color);
|
||||
--background-start: 0 0% 3%;
|
||||
--background-end: 10 8% 15%;
|
||||
--background-start: hsl(0 0% 3%);
|
||||
--background-end: hsl(10 8% 15%);
|
||||
--foreground: var(--fg-color);
|
||||
--card: var(--bg-color);
|
||||
--card-foreground: var(--fg-color);
|
||||
|
|
@ -53,57 +171,52 @@
|
|||
--secondary: var(--secondary-bg);
|
||||
--secondary-foreground: var(--fg-color);
|
||||
--muted: var(--secondary-bg);
|
||||
--muted-foreground: 240 5% 74%;
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: var(--secondary-bg);
|
||||
--accent-foreground: var(--fg-color);
|
||||
--info: 207 90% 54%;
|
||||
--info-foreground: 210 40% 90%;
|
||||
--success: 122 39% 49%;
|
||||
--success-foreground: 210 40% 90%;
|
||||
--warning: 52.15, 100%, 46.47%;
|
||||
--warning-foreground: 210 40% 90%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--info: hsl(207 90% 54%);
|
||||
--info-foreground: hsl(210 40% 90%);
|
||||
--success: hsl(122 39% 49%);
|
||||
--success-foreground: hsl(210 40% 90%);
|
||||
--warning: hsl(52.15, 100%, 46.47%);
|
||||
--warning-foreground: hsl(240 10% 13%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: var(--fg-color);
|
||||
--border: var(--secondary-bg);
|
||||
--input: var(--secondary-bg);
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
--toastify-font-family: var(--font-sans);
|
||||
--toastify-color-light: var(--background);
|
||||
/*--toastify-color-info: #3498db;*/
|
||||
--toastify-color-success: var(--success);
|
||||
/*--toastify-color-warning: #f1c40f;*/
|
||||
--toastify-color-error: var(--destructive);
|
||||
/*--toastify-color-transparent: rgba(255, 255, 255, 0.7);*/
|
||||
|
||||
--toastify-icon-color-info: var(--toastify-color-info);
|
||||
--toastify-icon-color-success: var(--toastify-color-success);
|
||||
--toastify-icon-color-warning: var(--toastify-color-warning);
|
||||
--toastify-icon-color-error: var(--toastify-color-error);
|
||||
|
||||
/* size and fonts are not customized */
|
||||
|
||||
--toastify-text-color-light: var(--foreground);
|
||||
|
||||
--toastify-color-progress-info: var(--toastify-color-info);
|
||||
--toastify-color-progress-success: var(--toastify-color-success);
|
||||
--toastify-color-progress-warning: var(--toastify-color-warning);
|
||||
--toastify-color-progress-error: var(--toastify-color-error);
|
||||
|
||||
.Toastify__toast {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.system {
|
||||
--bg-color: 240 10% 13%;
|
||||
--fg-color: 240 10% 85%;
|
||||
--secondary-bg: 240 3.7% 19%;
|
||||
--primary-fg: 240 5.9% 15%;
|
||||
|
||||
--background: var(--bg-color);
|
||||
--background-start: 0 0% 3%;
|
||||
--background-end: 10 8% 15%;
|
||||
--foreground: var(--fg-color);
|
||||
--card: var(--bg-color);
|
||||
--card-foreground: var(--fg-color);
|
||||
--popover: var(--bg-color);
|
||||
--popover-foreground: var(--fg-color);
|
||||
--primary: var(--fg-color);
|
||||
--primary-foreground: var(--primary-fg);
|
||||
--secondary: var(--secondary-bg);
|
||||
--secondary-foreground: var(--fg-color);
|
||||
--muted: var(--secondary-bg);
|
||||
--muted-foreground: 240 5% 74%;
|
||||
--accent: var(--secondary-bg);
|
||||
--accent-foreground: var(--fg-color);
|
||||
--info: 207 90% 54%;
|
||||
--info-foreground: 210 40% 90%;
|
||||
--success: 122 39% 49%;
|
||||
--success-foreground: 210 40% 90%;
|
||||
--warning: 52.15, 100%, 46.47%;
|
||||
--warning-foreground: 210 40% 90%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--fg-color);
|
||||
--border: var(--secondary-bg);
|
||||
--input: var(--secondary-bg);
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
.Toastify__close-button--light {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,20 +225,20 @@
|
|||
@apply border-border;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role="button"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
color: hsl(var(--foreground));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
hsl(var(--background-end))
|
||||
) hsl(var(--background-start));
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
background: linear-gradient(to bottom, transparent, var(--background-end))
|
||||
var(--background-start);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,43 +276,18 @@ code {
|
|||
@apply font-mono;
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/*
|
||||
this is a ad-hoc way to apply toastify variables.
|
||||
We could not find way to correctly order the toastify css and this css so put in body to get higher specificity
|
||||
*/
|
||||
body {
|
||||
--toastify-color-light: hsl(var(--background));
|
||||
/*--toastify-color-info: #3498db;*/
|
||||
--toastify-color-success: hsl(var(--success));
|
||||
/*--toastify-color-warning: #f1c40f;*/
|
||||
--toastify-color-error: hsl(var(--destructive));
|
||||
/*--toastify-color-transparent: rgba(255, 255, 255, 0.7);*/
|
||||
|
||||
--toastify-icon-color-info: var(--toastify-color-info);
|
||||
--toastify-icon-color-success: var(--toastify-color-success);
|
||||
--toastify-icon-color-warning: var(--toastify-color-warning);
|
||||
--toastify-icon-color-error: var(--toastify-color-error);
|
||||
|
||||
/* size and fonts are not customized */
|
||||
|
||||
--toastify-text-color-light: hsl(var(--foreground));
|
||||
|
||||
--toastify-color-progress-info: var(--toastify-color-info);
|
||||
--toastify-color-progress-success: var(--toastify-color-success);
|
||||
--toastify-color-progress-warning: var(--toastify-color-warning);
|
||||
--toastify-color-progress-error: var(--toastify-color-error);
|
||||
|
||||
.Toastify__toast {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.Toastify__close-button--light {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Radix ui sets display:block for each scroll viewport element but it seem it make worse */
|
||||
[data-radix-scroll-area-viewport] > div {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: necessary to override element */
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +295,22 @@ body {
|
|||
* Add padding end for horizontal scroll bar of scrollable card if vertical scroll bar is invisible
|
||||
* This prevents the horizontal scroll bar hide corner of the card
|
||||
*/
|
||||
.vrc-get-scrollable-card:not(:has(> .vrc-get-scrollable-card-vertical-bar)) > div[data-radix-scroll-area-viewport] > div > div.vrc-get-scrollable-card-horizontal-bar {
|
||||
.vrc-get-scrollable-card:not(
|
||||
:has(> .vrc-get-scrollable-card-vertical-bar)
|
||||
) > div[data-radix-scroll-area-viewport]
|
||||
> div
|
||||
> div.vrc-get-scrollable-card-horizontal-bar {
|
||||
@apply pe-2.5;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add padding end for the content area of scrollable card if vertical scroll bar is visible
|
||||
* This prevents the table / items from being hidden behind the vertical scroll bar
|
||||
*/
|
||||
.vrc-get-scrollable-card:has(
|
||||
> .vrc-get-scrollable-card-vertical-bar
|
||||
) > div[data-radix-scroll-area-viewport]
|
||||
> div {
|
||||
@apply pe-2.5;
|
||||
}
|
||||
|
||||
|
|
@ -215,3 +318,51 @@ body {
|
|||
contain-intrinsic-size: 0 7em;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.slide-left {
|
||||
animation: slideLeft 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.slide-right {
|
||||
animation: slideRight 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
vrc-get-gui/app/index.tsx
Normal file
11
vrc-get-gui/app/index.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import ErrorPage from "@/app/-error";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: RouteComponent,
|
||||
errorComponent: ErrorPage,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "react-toastify/ReactToastify.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useHtmlLang: we cannot determine the language of the content. we add in inner div.
|
||||
<html>
|
||||
<head>
|
||||
{/* empty png to avoid erros */}
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script src="vrc-get://localhost/global-info.js" />
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script src="http://vrc-get.localhost/global-info.js" />
|
||||
</head>
|
||||
<body
|
||||
className={
|
||||
"font-sans w-screen h-screen flex flex-row overflow-hidden whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"files": {
|
||||
"ignore": [
|
||||
"templates",
|
||||
"node_modules",
|
||||
".next",
|
||||
"out",
|
||||
"gen",
|
||||
"lib/bindings.ts",
|
||||
"build"
|
||||
"includes": [
|
||||
"**",
|
||||
"!project-templates",
|
||||
"!node_modules",
|
||||
"!.next",
|
||||
"!out",
|
||||
"!gen",
|
||||
"!lib/bindings.ts",
|
||||
"!lib/routeTree.gen.ts",
|
||||
"!build"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
|
@ -23,9 +25,20 @@
|
|||
// In my opinion, '!.' => '?.' is not reasonable for all cases, so I disabled automatic fix.
|
||||
"fix": "none",
|
||||
"level": "error"
|
||||
},
|
||||
"noRestrictedGlobals": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"deniedGlobals": {
|
||||
"close": "window.close is unlikely to be called"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"suspicious": {
|
||||
// false positives with tailwind css
|
||||
// see https://github.com/biomejs/biome/issues/7223
|
||||
"noUnknownAtRules": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"correctness": {
|
||||
|
|
@ -37,16 +50,41 @@
|
|||
"name": "useDocumentEvent",
|
||||
"closureIndex": 1,
|
||||
"dependenciesIndex": 2
|
||||
},
|
||||
{
|
||||
"name": "useEffectEvent",
|
||||
"stableResult": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"inputComponents": [
|
||||
"Checkbox",
|
||||
"SelectTrigger",
|
||||
"BackupFormatSelect"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
vrc-get-gui/booth/.gitignore
vendored
Normal file
3
vrc-get-gui/booth/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
thumbnail.png
|
||||
booth.zip
|
||||
app-icon.png
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue