mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f900b96e82 | ||
|
|
9bae34d0ed | ||
|
|
4b4b7d4449 | ||
|
|
77e1bc3214 | ||
|
|
f3207ed184 | ||
|
|
7a47187e37 | ||
|
|
9b1bb94a25 | ||
|
|
f486429612 | ||
|
|
f6f198777b | ||
|
|
ff897f1aa7 | ||
|
|
886764fde9 | ||
|
|
d98c571c94 | ||
|
|
4a2ff2e728 | ||
|
|
7e572eb649 | ||
|
|
ea7ab22e0c | ||
|
|
24e4a548b2 | ||
|
|
5b5f6cc79d | ||
|
|
ca1eb4dd41 | ||
|
|
f9c3200aa4 | ||
|
|
4d5e489f19 | ||
|
|
5c8e932060 | ||
|
|
47e6f1ca36 | ||
|
|
6da543aaec | ||
|
|
0a7bd1a45e | ||
|
|
f980fd9433 | ||
|
|
83b786373d | ||
|
|
3120045cba | ||
|
|
5c1d8e4f41 | ||
|
|
e9a3ab5936 | ||
|
|
7d0a310078 | ||
|
|
6a609554a9 | ||
|
|
9a4f000493 | ||
|
|
fb541414df | ||
|
|
225b5cbba6 | ||
|
|
9d3eabb593 | ||
|
|
fc535b95b1 | ||
|
|
4ca3e22aa7 | ||
|
|
0b1d08733f | ||
|
|
e958a464ad | ||
|
|
ce3bde5ee4 | ||
|
|
2a8dba4818 | ||
|
|
b66fea9fd0 | ||
|
|
6df1741a04 | ||
|
|
f0dc400f26 | ||
|
|
891c80d295 | ||
|
|
41eeff78d0 | ||
|
|
b2a57095c0 | ||
|
|
0ad0a99dbe | ||
|
|
71743a72f9 | ||
|
|
57094250d3 | ||
|
|
34c5a6993e | ||
|
|
d8af81ef5d | ||
|
|
10a9ee2ce8 | ||
|
|
97711d9ec1 | ||
|
|
fc3597b14b | ||
|
|
3598d15d04 | ||
|
|
cdd7a671f2 | ||
|
|
63ab561024 | ||
|
|
cdf7bc737c | ||
|
|
db40058ded | ||
|
|
556134ddfb | ||
|
|
0d3b3a79af | ||
|
|
d3b9cf8fe0 | ||
|
|
85d68ded2f | ||
|
|
80839d7f64 | ||
|
|
3154debda3 | ||
|
|
10e8248156 | ||
|
|
31e368451f | ||
|
|
df81e0e96d | ||
|
|
9cf6be1ef3 | ||
|
|
876fb858af | ||
|
|
dd6538187b | ||
|
|
9c680a23e6 | ||
|
|
517f751066 | ||
|
|
e8b54f3109 | ||
|
|
5154cd2f30 | ||
|
|
f169d6251c | ||
|
|
0945361a23 | ||
|
|
921b273c08 | ||
|
|
24223d9e6e | ||
|
|
043a2666d0 | ||
|
|
fb2cbeff1d | ||
|
|
03614784e4 | ||
|
|
757066e67f | ||
|
|
ccefbc2506 | ||
|
|
7a3a317714 | ||
|
|
a1d2dfe42b | ||
|
|
3ee742d22b | ||
|
|
80c3f10cc5 | ||
|
|
414ed0d5b6 | ||
|
|
573e0feaf7 | ||
|
|
2666fa5abb | ||
|
|
1ca53d8a15 | ||
|
|
4d716ac057 | ||
|
|
e0dd174aea | ||
|
|
a5cd0597be | ||
|
|
2762485d9e | ||
|
|
affe36814f | ||
|
|
29c4d12e82 | ||
|
|
dc279ab466 | ||
|
|
d3e790c1d2 | ||
|
|
3c393380e5 | ||
|
|
e1010aae0e | ||
|
|
fb491c70bc | ||
|
|
db18de0303 | ||
|
|
c86d787371 | ||
|
|
a2b003d6ed | ||
|
|
70537fef3e | ||
|
|
7313b654d9 | ||
|
|
e6ffbd3f68 | ||
|
|
6e55d2d498 | ||
|
|
929456a231 | ||
|
|
d9b0a06569 | ||
|
|
3af90b6e8f | ||
|
|
3abc44f952 | ||
|
|
892f3f6ff9 | ||
|
|
3e4fb1b411 | ||
|
|
af3459ed6e | ||
|
|
8c4cfffc77 | ||
|
|
ca0c44d148 | ||
|
|
0ea83c802f | ||
|
|
35696a8a2a | ||
|
|
e1977e4c7f | ||
|
|
c7cfa5d218 | ||
|
|
82bc4e21d1 | ||
|
|
217b05d969 | ||
|
|
e2a1b10e2b | ||
|
|
9bd41fdcbe | ||
|
|
646f705ca0 | ||
|
|
ae3801dd37 | ||
|
|
30fa8c4acd | ||
|
|
026ac21ecc | ||
|
|
9a0956595c | ||
|
|
7edb80ef59 | ||
|
|
f988845a53 | ||
|
|
0d50f69d92 | ||
|
|
7f7ca3a8e0 | ||
|
|
0a00f3f721 | ||
|
|
7b8e4896c8 | ||
|
|
987032ffa5 | ||
|
|
0d6513c14e | ||
|
|
fdbc5c693e | ||
|
|
3ae7662122 | ||
|
|
f2dc48e8b6 | ||
|
|
932af63b26 | ||
|
|
9833970dc6 | ||
|
|
47909d4ae6 | ||
|
|
a4b27e4383 | ||
|
|
71825edc1f | ||
|
|
ae2858a2cc | ||
|
|
064677f5c7 | ||
|
|
8c2db502f1 | ||
|
|
9ca0515a7c | ||
|
|
a4f3f3f630 | ||
|
|
45db7fe61c | ||
|
|
df5d02cbca | ||
|
|
d0aaeb82a9 | ||
|
|
3bfdcd1e8e | ||
|
|
10e1c2b338 | ||
|
|
f642f53c3a | ||
|
|
02ae16a481 | ||
|
|
c15b36412e | ||
|
|
31d42f3e45 | ||
|
|
b67f365e65 | ||
|
|
52eedb65d0 | ||
|
|
8136479c6e | ||
|
|
e91002d42c | ||
|
|
09afc1e24d | ||
|
|
9e37c6d725 | ||
|
|
1bb83acac1 | ||
|
|
2362fb5ee5 | ||
|
|
d2c3605bf2 | ||
|
|
1fd7305a24 | ||
|
|
6a6435c295 | ||
|
|
77d5e3394e | ||
|
|
f3c714f458 | ||
|
|
c5de950c10 | ||
|
|
c73a24a806 | ||
|
|
9cefd5f4b8 | ||
|
|
cbb519caa7 | ||
|
|
c19d1bcd82 | ||
|
|
ca959e4e25 | ||
|
|
e75a467e40 | ||
|
|
c818e56100 | ||
|
|
a8f6d194df | ||
|
|
c4f24d9b0c | ||
|
|
dd8837ea42 | ||
|
|
40d60e8e4e | ||
|
|
fac3836e74 | ||
|
|
a38662a145 | ||
|
|
2220af3d62 | ||
|
|
6d3a4dae90 | ||
|
|
004645c81c | ||
|
|
9a3540fb5a | ||
|
|
bccf594e6c | ||
|
|
612c60eedd | ||
|
|
a83216c30b | ||
|
|
4068973ac7 | ||
|
|
242b0c1815 | ||
|
|
de82c0bea3 | ||
|
|
17ce14cb8f | ||
|
|
789f9d5007 | ||
|
|
500f4cea2c | ||
|
|
5f3569778a | ||
|
|
46ac20f9a5 | ||
|
|
96f5f56035 | ||
|
|
34f63b7103 | ||
|
|
1de79b08d2 | ||
|
|
9c69a3276d | ||
|
|
a16518b587 | ||
|
|
a6fb423002 | ||
|
|
a11b02e4a2 | ||
|
|
5644e2e391 | ||
|
|
09d8e17c52 | ||
|
|
95319be115 | ||
|
|
b3fe14d9d5 | ||
|
|
b1ae300878 | ||
|
|
0d35d67fbb | ||
|
|
c68296ce56 | ||
|
|
09f18a4a0e | ||
|
|
7aa57fd190 | ||
|
|
1728b208b2 | ||
|
|
857e9b40e2 | ||
|
|
fb7e7e66d1 | ||
|
|
e8c1c337b9 | ||
|
|
13e7a27938 | ||
|
|
770926cdfa | ||
|
|
42b7e4090d | ||
|
|
59a28c3b7e | ||
|
|
cfa6780fd1 | ||
|
|
43d9bd33f7 | ||
|
|
331776812d | ||
|
|
7203a61914 | ||
|
|
e627f04a9e | ||
|
|
a54f0ce5c9 | ||
|
|
59b54367bf | ||
|
|
5a97403b1d | ||
|
|
535c21757c | ||
|
|
1dcb843262 | ||
|
|
331ee466ad | ||
|
|
ea08b7c990 | ||
|
|
da1d70dfb4 | ||
|
|
4922959cd7 | ||
|
|
cd5922d7c2 | ||
|
|
a7491245a4 | ||
|
|
531cbf158c | ||
|
|
7e2899bba5 | ||
|
|
0d5c5e815f | ||
|
|
87333ac85a | ||
|
|
bd53a67cc1 | ||
|
|
5074e255b5 | ||
|
|
4055a27b24 | ||
|
|
0820833414 | ||
|
|
b8144d1d04 | ||
|
|
545599c745 | ||
|
|
1303c655b7 | ||
|
|
d1f013d93e | ||
|
|
a4c876bba1 | ||
|
|
76bb1d7608 | ||
|
|
9e27ab849c | ||
|
|
a2796b9b39 | ||
|
|
7059007bf4 | ||
|
|
07cc7efcb2 | ||
|
|
9d8f4cd816 | ||
|
|
b399300dc7 | ||
|
|
64451976f4 | ||
|
|
b386cb07d3 | ||
|
|
b3c24e3176 | ||
|
|
7ae2a33063 | ||
|
|
346634db12 | ||
|
|
05f933da05 | ||
|
|
37f0fea015 | ||
|
|
855f3a9a68 | ||
|
|
baae4467a0 | ||
|
|
503e92f789 | ||
|
|
e4021137a9 | ||
|
|
11695f0ec9 | ||
|
|
8db55cd832 | ||
|
|
d5eed6be4c | ||
|
|
a08ce5412e | ||
|
|
6782f70f84 | ||
|
|
5356640e33 | ||
|
|
f1a7c0c397 | ||
|
|
aabe4119f2 | ||
|
|
def2fbd509 | ||
|
|
4db54ba16c | ||
|
|
5d6ff2def0 | ||
|
|
c9f30a541c | ||
|
|
c581815795 | ||
|
|
475a5c4628 | ||
|
|
45966c5177 | ||
|
|
638e8db691 | ||
|
|
c10405a9b5 | ||
|
|
4135a27c09 | ||
|
|
da6881079c | ||
|
|
2b932d9e8e | ||
|
|
f7f4a93288 | ||
|
|
a2b9365303 | ||
|
|
8a95fa1eda | ||
|
|
9bd2274adf | ||
|
|
7538dd0b9f | ||
|
|
5ddf259e05 | ||
|
|
c83dc860ea | ||
|
|
7f1eb9654e | ||
|
|
06a80f7d94 | ||
|
|
2ff2013b4c | ||
|
|
8c7ecf7e3e | ||
|
|
f807650bc0 | ||
|
|
c78276536e | ||
|
|
25a0afd02c | ||
|
|
6133d0c610 | ||
|
|
39ae675f68 | ||
|
|
256dc4df76 | ||
|
|
530738a485 | ||
|
|
23f3c6cdf8 | ||
|
|
03c1e4c1d4 | ||
|
|
aa900decb6 | ||
|
|
9eea333672 | ||
|
|
87dc2c2f8c | ||
|
|
1749515060 | ||
|
|
52b068e132 | ||
|
|
460ed1daa3 | ||
|
|
0a073d10f2 | ||
|
|
4b6d8e820a | ||
|
|
634cec633c | ||
|
|
9ab4f46492 | ||
|
|
90bc307074 | ||
|
|
502161038b | ||
|
|
afc04924ff | ||
|
|
a82e6e46bc | ||
|
|
67cc864af4 | ||
|
|
d8a0463e94 | ||
|
|
c58332ab00 | ||
|
|
5211fe1d58 | ||
|
|
c99eff6080 | ||
|
|
9dc60485fa | ||
|
|
65811a9f63 | ||
|
|
44807273bc | ||
|
|
095213dd42 | ||
|
|
2d8d4ca2ea | ||
|
|
4ec53e2acd | ||
|
|
2280664ac9 | ||
|
|
69ccedb62d | ||
|
|
fa4d0d5348 | ||
|
|
18b2f435bc | ||
|
|
67b40cd89e | ||
|
|
0855c6d5ca | ||
|
|
39ee27d181 | ||
|
|
2a58bb8def | ||
|
|
a80b1bfbaa | ||
|
|
f578b1dbe3 | ||
|
|
9b5fe7af86 | ||
|
|
326c30e4a6 | ||
|
|
14e212ae4a | ||
|
|
dd135ff568 | ||
|
|
94f9a5f37c | ||
|
|
16a5b9ecec | ||
|
|
5806a8bad1 | ||
|
|
c1d88b2c8b | ||
|
|
0a1a20d6fc | ||
|
|
9641e6ebe1 | ||
|
|
ae2a7803fb | ||
|
|
8b8fbed6aa | ||
|
|
d64adce10a | ||
|
|
fc4326fe10 | ||
|
|
d422bda771 | ||
|
|
982a252edf | ||
|
|
88032e57fb | ||
|
|
5edd683351 | ||
|
|
46422b574d | ||
|
|
77352bbb69 | ||
|
|
d19aa89caa | ||
|
|
c991f16079 | ||
|
|
e3956d6692 | ||
|
|
d4eb971d11 | ||
|
|
f2b189c2ed | ||
|
|
7df9ca6f43 | ||
|
|
77c34c60f8 | ||
|
|
c95d86b470 | ||
|
|
034fa64451 | ||
|
|
6794d192a0 | ||
|
|
ee05670ad7 | ||
|
|
ddf770be48 | ||
|
|
b565f6dbb6 | ||
|
|
90b3238837 | ||
|
|
07d74e0d1b | ||
|
|
a76e4c7961 | ||
|
|
f3b899bbcb | ||
|
|
6573afc3f0 | ||
|
|
28d91a1717 | ||
|
|
0a71ccfc93 | ||
|
|
d3e77bd20f | ||
|
|
2c0d5209b1 | ||
|
|
42f2905063 | ||
|
|
fe494cfde2 | ||
|
|
9992575a79 | ||
|
|
f0a282a7a2 | ||
|
|
5499c98fca | ||
|
|
7cafe7872e | ||
|
|
6e33067b60 | ||
|
|
efc00a5194 | ||
|
|
e991507fac | ||
|
|
092d899e82 | ||
|
|
51df323075 | ||
|
|
8eb78d6278 | ||
|
|
b9caf14363 | ||
|
|
e77a5a027b | ||
|
|
48a5834c52 | ||
|
|
6971bacb38 | ||
|
|
5ac51bb139 | ||
|
|
6f44c5b9ac | ||
|
|
239f2c77d3 | ||
|
|
8bb7d261e1 | ||
|
|
540b1f5291 | ||
|
|
06e51eb1d5 | ||
|
|
62cf5365f7 | ||
|
|
39783cc5a5 | ||
|
|
ae31457a52 | ||
|
|
238d3d0bc6 | ||
|
|
a2d8ba66ea | ||
|
|
000bc9fd27 | ||
|
|
9684433cc8 | ||
|
|
6854b82ef3 | ||
|
|
672528d3e9 | ||
|
|
72abd63315 | ||
|
|
e060e818d0 | ||
|
|
88ab3d2a71 | ||
|
|
bd098a45fb | ||
|
|
8ef487eece | ||
|
|
e96c344240 | ||
|
|
d05b65d570 | ||
|
|
8173e1de61 | ||
|
|
7dbe9ec864 | ||
|
|
e60c22a99a | ||
|
|
bcbf8933e5 | ||
|
|
4f8d98f50e | ||
|
|
f13d9bc684 | ||
|
|
6686ec9c7c | ||
|
|
c8d585f75a | ||
|
|
687e4f9d02 | ||
|
|
7a651aae3d | ||
|
|
7a6a9f2772 | ||
|
|
939f15ab9b | ||
|
|
4b5c3427b3 | ||
|
|
621fae1d8c | ||
|
|
0a265edf2a | ||
|
|
950600649e | ||
|
|
44ad052b3c | ||
|
|
6227f24091 | ||
|
|
d9d01decf7 | ||
|
|
258f15189b | ||
|
|
50c3d21e0f | ||
|
|
bd4768d7a7 | ||
|
|
0999142fe3 | ||
|
|
0a0a91875d | ||
|
|
a042b6b90c | ||
|
|
cabd6dc4f8 | ||
|
|
f10bbc70d0 | ||
|
|
94749e92ef | ||
|
|
c488a977f1 | ||
|
|
1aaa9763d1 | ||
|
|
7ab441fbe1 | ||
|
|
7a367e61b7 | ||
|
|
e46ea3caf3 | ||
|
|
6c4a88e517 | ||
|
|
cedf13933f | ||
|
|
d354ccbb2c | ||
|
|
568a8f0ab7 | ||
|
|
9b69b3c949 | ||
|
|
a926a36e35 | ||
|
|
a1079bd7d4 | ||
|
|
cbe88e33a7 | ||
|
|
050cec3be0 | ||
|
|
5a54075ac1 | ||
|
|
1bb6eb9011 | ||
|
|
b73b73a7ee | ||
|
|
7febde4c4d | ||
|
|
3f00d9f3b0 | ||
|
|
ae7171431d | ||
|
|
03f47a51d0 | ||
|
|
26d113b8bf | ||
|
|
788d525a7d | ||
|
|
47623c7324 | ||
|
|
bafb52e283 | ||
|
|
1480693ebd | ||
|
|
3634841f9f | ||
|
|
6f9aa31650 | ||
|
|
9a2d258770 | ||
|
|
65fb48fd44 | ||
|
|
43fcfecc3a | ||
|
|
5e9fb24c12 | ||
|
|
54ed15b2f8 | ||
|
|
d1b8d97c0c | ||
|
|
857dc789fc | ||
|
|
b58a78021d | ||
|
|
25986252fd | ||
|
|
e3510f8a50 | ||
|
|
5d6b6b9769 | ||
|
|
158d5c144a | ||
|
|
783c45801b | ||
|
|
29c8b67958 | ||
|
|
aa2cb79c9e | ||
|
|
ec257558c1 | ||
|
|
efa3d768cd | ||
|
|
3569cb9a87 | ||
|
|
f6de13b4aa | ||
|
|
69d8eac51c | ||
|
|
8f970a43b8 | ||
|
|
c462e4f835 | ||
|
|
ebd5f0fc44 | ||
|
|
40eb7b99dd | ||
|
|
c23dc5dd88 | ||
|
|
a47df43707 | ||
|
|
55bc4559d9 | ||
|
|
f5f93cf8f5 | ||
|
|
48342ae059 | ||
|
|
0046291376 | ||
|
|
7cb722a5c5 | ||
|
|
e6b4256d5d | ||
|
|
219f98deb5 | ||
|
|
ca10c6ee92 | ||
|
|
08cc1607f9 | ||
|
|
e979f243e0 | ||
|
|
a1e77dab8e | ||
|
|
a03864da41 | ||
|
|
80ad3f73cc | ||
|
|
0550bd36d5 | ||
|
|
86390568fb | ||
|
|
c3a364b519 | ||
|
|
468f196a9d | ||
|
|
60017871fc | ||
|
|
e578ecab4c | ||
|
|
a5bbe2ac2f | ||
|
|
c11502c31f | ||
|
|
af6b7645b4 | ||
|
|
daaac5f3e4 | ||
|
|
eb3c49cdbe | ||
|
|
f382677b48 | ||
|
|
2fbc5f87c0 | ||
|
|
64e2140b44 | ||
|
|
54ceb2b85e | ||
|
|
4233aef01a | ||
|
|
f42c9dbfec | ||
|
|
c0abf0c3d5 | ||
|
|
f61690ab05 | ||
|
|
535b28a43e | ||
|
|
c096a64d7a | ||
|
|
4631b1e037 | ||
|
|
400a4829b9 | ||
|
|
8b104a6813 | ||
|
|
ffa2289db8 | ||
|
|
4b3bdfb033 | ||
|
|
a810ec4fd4 | ||
|
|
04981b2996 | ||
|
|
2cabefce99 | ||
|
|
8abbd36baf | ||
|
|
327ef62f90 | ||
|
|
1520040b6e | ||
|
|
094743a3f8 | ||
|
|
b17bc15c48 | ||
|
|
56216dbf47 | ||
|
|
83e65471b3 | ||
|
|
09cd907b85 | ||
|
|
33f37b5f53 | ||
|
|
ebcc82a2f4 | ||
|
|
32221dce0b | ||
|
|
fad16446cd | ||
|
|
a627ec56c9 | ||
|
|
1a426c4d07 | ||
|
|
1e93f91c62 | ||
|
|
76442e3240 | ||
|
|
01e7edc006 | ||
|
|
46c4be135f | ||
|
|
15d654f785 | ||
|
|
2b13673512 | ||
|
|
3ea5f295d2 | ||
|
|
897f7c901f | ||
|
|
3085bfdfce | ||
|
|
8d781af4f0 | ||
|
|
9f2669645b |
No files matched your search
@@ -1,234 +0,0 @@
|
||||
name: build-mosh-binaries
|
||||
|
||||
# Trigger philosophy (mirrors build.yml):
|
||||
# - Pushes that touch the mosh build pipeline + PRs run the matrix
|
||||
# so we can validate workflow / script changes without tagging.
|
||||
# Artifacts upload as workflow artifacts only; *no* release.
|
||||
# - Manual `workflow_dispatch` with `release_tag` publishes the
|
||||
# binaries + SHA256SUMS to the dedicated binary repository
|
||||
# (`binaricat/Netcatty-mosh-bin` by default).
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
|
||||
# or refreshing mosh binaries on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mosh_ref:
|
||||
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
|
||||
type: string
|
||||
default: "mosh-1.4.0"
|
||||
release_tag:
|
||||
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
|
||||
type: string
|
||||
default: ""
|
||||
release_repo:
|
||||
description: "Repository that stores mosh-client binary releases."
|
||||
type: string
|
||||
default: "binaricat/Netcatty-mosh-bin"
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/build-mosh-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-mosh/**"
|
||||
- "scripts/fetch-mosh-binaries.cjs"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/build-mosh-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-mosh/**"
|
||||
- "scripts/fetch-mosh-binaries.cjs"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
|
||||
# Cancel superseded branch / PR builds.
|
||||
concurrency:
|
||||
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
|
||||
# Static-links the heavy third-party deps where possible; the resulting
|
||||
# mosh-client still depends on baseline Linux system libraries.
|
||||
# ------------------------------------------------------------------
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (linux-x64)
|
||||
run: |
|
||||
# Run only the compiler inside manylinux2014. JavaScript actions
|
||||
# need the host runner's newer glibc.
|
||||
docker run --rm \
|
||||
-e MOSH_REF="${MOSH_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=x64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-linux-x64
|
||||
path: out/
|
||||
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (linux-arm64)
|
||||
run: |
|
||||
# Run only the compiler inside manylinux2014. JavaScript actions
|
||||
# need the host runner's newer glibc.
|
||||
docker run --rm \
|
||||
-e MOSH_REF="${MOSH_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=arm64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_aarch64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-linux-arm64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# macOS universal2 (arm64 + x86_64 lipo).
|
||||
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
|
||||
# Static-links OpenSSL, protobuf, ncurses for both arches.
|
||||
# ------------------------------------------------------------------
|
||||
build-macos-universal:
|
||||
name: build-macos-universal
|
||||
runs-on: macos-15-intel
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (darwin-universal)
|
||||
env:
|
||||
MOSH_REF: ${{ env.MOSH_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}/out
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
run: bash scripts/build-mosh/build-macos.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-darwin-universal
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 pinned runtime bundle.
|
||||
# Do not compile this in CI: rebuilding the upstream Cygwin client here
|
||||
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
|
||||
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
|
||||
# verified by fetch-windows.sh.
|
||||
# ------------------------------------------------------------------
|
||||
fetch-windows-x64:
|
||||
name: fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fetch pinned mosh-client bundle (win32-x64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OUT_DIR="${GITHUB_WORKSPACE}/out"
|
||||
mkdir -p "$OUT_DIR"
|
||||
bash scripts/build-mosh/fetch-windows.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-win32-x64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built.
|
||||
# The pinned upstream source only provides x64. arm64 Windows builds
|
||||
# should be added only after we have a tested standalone arm64 client.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregate + optional release to the dedicated binary repository.
|
||||
# ------------------------------------------------------------------
|
||||
release:
|
||||
name: release
|
||||
needs:
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos-universal
|
||||
- fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: Stage release files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
for d in artifacts/*/; do
|
||||
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
|
||||
done
|
||||
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
|
||||
ls -la release
|
||||
cat release/SHA256SUMS
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
tag="${RELEASE_TAG}"
|
||||
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Invalid mosh binary release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Create / update release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
|
||||
RELEASE_REPO: ${{ inputs.release_repo }}
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${GH_TOKEN:-}" ]]; then
|
||||
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
|
||||
printf '%s\n\n' 'Windows x64 is the SHA256-pinned Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
|
||||
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
|
||||
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
|
||||
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
|
||||
} > release-notes.md
|
||||
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file release-notes.md
|
||||
gh release upload "${RELEASE_TAG}" release/* \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--clobber
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" release/* \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file release-notes.md
|
||||
fi
|
||||
@@ -43,5 +43,44 @@ jobs:
|
||||
npm run generate:capability-tools
|
||||
git diff --exit-code infrastructure/ai/harness/generated/cattyToolSpecs.json
|
||||
|
||||
- name: Verify Codex App Server schema
|
||||
run: npm run check:codex-app-server-schema
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
mosh-windows-conpty:
|
||||
name: mosh-windows-conpty
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Compile ConPTY test helpers
|
||||
shell: pwsh
|
||||
run: |
|
||||
$compiler = "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
|
||||
if (-not (Test-Path $compiler)) { throw "csc.exe not found" }
|
||||
New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP\netcatty-mosh-conpty" | Out-Null
|
||||
& $compiler /nologo /target:exe /out:"$env:RUNNER_TEMP\netcatty-mosh-conpty\ssh.exe" electron\bridges\fixtures\mosh-conpty-fake-ssh.cs
|
||||
if ($LASTEXITCODE -ne 0) { throw "failed to compile fake ssh" }
|
||||
& $compiler /nologo /target:exe /out:"$env:RUNNER_TEMP\netcatty-mosh-conpty\mosh-client.exe" electron\bridges\fixtures\mosh-conpty-fake-client.cs
|
||||
if ($LASTEXITCODE -ne 0) { throw "failed to compile fake mosh-client" }
|
||||
|
||||
- name: Test Mosh handshake through ConPTY
|
||||
shell: pwsh
|
||||
env:
|
||||
NETCATTY_TEST_MOSH_SSH_EXE: ${{ runner.temp }}\netcatty-mosh-conpty\ssh.exe
|
||||
NETCATTY_TEST_MOSH_CLIENT_EXE: ${{ runner.temp }}\netcatty-mosh-conpty\mosh-client.exe
|
||||
run: |
|
||||
node --test electron/bridges/moshHandshake.test.cjs
|
||||
node electron/bridges/terminalBridge.moshConpty.integration.cjs
|
||||
+3
-8
@@ -77,16 +77,11 @@ Directory.Build.targets
|
||||
build_with_vs.bat
|
||||
build_with_vs2022.bat
|
||||
|
||||
# Bundled mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
|
||||
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
|
||||
# and the bundled ncurses terminfo database are all pulled from the
|
||||
# dedicated mosh binary repository, never committed.
|
||||
# Bundled MoshCatty mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is committed;
|
||||
# pure single-binary artifacts from binaricat/MoshCatty are never committed.
|
||||
/resources/mosh/*/mosh-client
|
||||
/resources/mosh/*/mosh-client.exe
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
/resources/mosh/*/terminfo/
|
||||
|
||||
# Bundled EternalTerminal `et` client binaries fetched at pack time by
|
||||
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
|
||||
|
||||
@@ -5,6 +5,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
## Current Agents (Roles)
|
||||
- **Domain** (`domain/`): Models and pure helpers. Examples:
|
||||
- `models.ts` defines Host/SSHKey/Snippet/Workspace entities.
|
||||
- `agentActivity.ts` defines persisted agent activity and token usage records.
|
||||
- `host.ts` handles distro normalization and host sanitization.
|
||||
- `workspace.ts` contains workspace tree operations (split/insert/prune/sizing).
|
||||
- **Application State** (`application/state/`): Hooks that own state and persistence boundaries.
|
||||
@@ -34,8 +35,29 @@ Turn orchestration is centralized in **AgentRuntime**; the React hook `useAIChat
|
||||
| Tools | `capabilityTools.ts`, `toolOutputStore.ts`, `toolResultDedup.ts` | Catalog tools, truncated output handles (`tool_output_read`), duplicate-read notices |
|
||||
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage`, `performance`, and `CompactionTrace` |
|
||||
|
||||
External SDK turns normalize file changes, web searches, plan updates, recoverable warnings, and token usage into the shared event protocol. These activity and usage records are stored on assistant messages so the compact activity view is restored with chat history.
|
||||
|
||||
**Stop** always goes through `stopAgentTurn()` (UI, `/stop`, MCP). Do not add parallel abort paths in hooks.
|
||||
|
||||
### Codex App Server (experimental)
|
||||
|
||||
Codex can opt into a persistent `codex app-server --stdio` runtime under
|
||||
`electron/bridges/aiBridge/codexAppServer/`; the existing TypeScript SDK remains
|
||||
the default. The main process owns JSONL RPC correlation, thread/turn routing,
|
||||
native approvals, `request_user_input`, model discovery, and process cleanup.
|
||||
|
||||
- Session identities include the Codex runtime; SDK and App Server threads must
|
||||
never resume across runtimes.
|
||||
- Observer maps to `read-only + never`, Confirm to `read-only + on-request`, and
|
||||
Auto to `danger-full-access + never`.
|
||||
- App Server native “allow for session” decisions are session-scoped Codex
|
||||
grants and must not become persistent Netcatty permission grants.
|
||||
- `turn/completed` is the terminal lifecycle event. Retryable `error`
|
||||
notifications are warnings; process exit is fatal.
|
||||
- Regenerate the committed protocol contract with
|
||||
`npm run generate:codex-app-server-schema` after upgrading Codex, and verify it
|
||||
with `npm run check:codex-app-server-schema`.
|
||||
|
||||
### AI SDK v7 (Catty path)
|
||||
|
||||
Catty sidebar turns use **Vercel AI SDK 7** via `streamText` in `turnDrivers/cattyStreamProcessor.ts`. Key conventions:
|
||||
@@ -84,6 +106,31 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime (internal preview)
|
||||
|
||||
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
|
||||
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
|
||||
types still come only from `packages/plugin-contract/schema/`; do not add a
|
||||
second private RPC shape when extending the host.
|
||||
|
||||
- `PackageStore` validates an immutable `.ncpkg` snapshot, extracts only into
|
||||
private staging, and atomically publishes an installed version.
|
||||
- `PluginManager` serializes install, enable/disable, restart and uninstall
|
||||
mutations. Do not bypass it from renderer IPC.
|
||||
- Ordinary plugins run in a sandboxed, offline `BrowserWindow` session and can
|
||||
reach only their runtime-scoped `netcatty-plugin://` authority.
|
||||
- Node-only plugins run in a dedicated `utilityProcess`; they remain an advanced
|
||||
development-only path until permission and distribution phases land.
|
||||
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
|
||||
protocol-failure containment. Runtime identity is assigned by the host and is
|
||||
never accepted from request parameters.
|
||||
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
|
||||
not add another independent quit interception path.
|
||||
|
||||
Run `npm run test:plugin-runtime` for main-process boundaries and
|
||||
`npm run test:plugin-runtime:electron` for real BrowserWindow/utilityProcess
|
||||
smoke coverage. Packaged-resource changes must also pass `npm run pack:dir`.
|
||||
|
||||
## Extending the System
|
||||
1) **New domain logic**: Add pure functions/types under `domain/`; avoid side effects.
|
||||
2) **New stateful behavior**: Wrap it in a hook under `application/state/`; keep external I/O behind adapters.
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
clearKeyPassphrasesByIds,
|
||||
loadDefaultKeyPassphrase,
|
||||
rememberKeyPassphrase,
|
||||
removeDefaultKeyPassphrases,
|
||||
removeDefaultKeyPassphraseAliases,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from './application/defaultKeyPassphrases';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -53,7 +53,10 @@ import {
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { syncExternalMcpStartupState } from './application/state/useExternalMcpToggleState';
|
||||
import {
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
syncExternalMcpStartupState,
|
||||
} from './application/state/useExternalMcpToggleState';
|
||||
import { useExternalMcpSessionSync } from './application/state/useExternalMcpSessionSync';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
@@ -272,6 +275,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
@@ -402,7 +406,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const {
|
||||
rules: portForwardingRules,
|
||||
importRules: importPortForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
hasRuntimeTunnel,
|
||||
} = usePortForwardingState();
|
||||
|
||||
// App-level External MCP session sync (before TerminalLayer lazy-mount).
|
||||
useExternalMcpSessionSync({
|
||||
@@ -551,6 +562,30 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
],
|
||||
);
|
||||
|
||||
const handleApplyConvergentSyncPayload = useCallback(
|
||||
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
await commitReplica();
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
enabled: !isPeerSessionWindow,
|
||||
@@ -568,6 +603,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
settingsVersion: settings.settingsVersion,
|
||||
startupReady: startupSyncSafetyReady,
|
||||
onApplyPayload: handleApplySyncPayload,
|
||||
onApplyConvergentPayload: handleApplyConvergentSyncPayload,
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
@@ -592,13 +628,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hasRuntimeTunnel, hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
useAppStartupEffects({ dismissUpdate, enabled: !isPeerSessionWindow, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, knownHosts: effectiveKnownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
useAppStartupEffects({ dismissUpdate, enabled: !isPeerSessionWindow, groupConfigs, hasRuntimeTunnel, hosts, identities, installUpdate, isVaultInitialized, keys, knownHosts: effectiveKnownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
@@ -759,13 +795,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keyPaths = event.keyPaths ?? [];
|
||||
const keyIds = event.keyIds ?? [];
|
||||
console.log('[App] Passphrase auth failed for keys:', { keyPaths, keyIds });
|
||||
removeDefaultKeyPassphrases(keyPaths);
|
||||
const withoutReferencePassphrases = clearReferenceKeyPassphrases(keysRef.current, keyPaths);
|
||||
const updated = clearKeyPassphrasesByIds(withoutReferencePassphrases, keyIds);
|
||||
if (updated !== keysRef.current) {
|
||||
keysRef.current = updated;
|
||||
void updateKeys(updated);
|
||||
}
|
||||
void removeDefaultKeyPassphraseAliases(keyPaths).then((aliases) => {
|
||||
if (keyPaths.length > 0 && aliases.length === 0) return;
|
||||
const withoutReferencePassphrases = clearReferenceKeyPassphrases(keysRef.current, aliases);
|
||||
const updated = clearKeyPassphrasesByIds(withoutReferencePassphrases, keyIds);
|
||||
if (updated !== keysRef.current) {
|
||||
keysRef.current = updated;
|
||||
void updateKeys(updated);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -853,8 +891,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => { return closeTabsBatchImpl(() => ({ closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds, workspaces }), targetIds); },
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
async (targetIds: string[]) => { return closeTabsBatchImpl(() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds, workspaces }), targetIds); },
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
@@ -1007,22 +1045,65 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
}, [createWorkspaceWithHosts, resolveEffectiveHost]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((hostId: string) => {
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
return { ok: false as const, error: `Host "${hostId}" was not found.` };
|
||||
const createWorkspaceFromEffectiveTargets = useCallback((
|
||||
targets: Parameters<typeof createWorkspaceFromTargets>[0],
|
||||
name?: string,
|
||||
) => createWorkspaceFromTargets(
|
||||
targets.map((target) => target.kind === 'host'
|
||||
? { ...target, host: resolveEffectiveHost(target.host) }
|
||||
: target),
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false) => {
|
||||
if (host.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
});
|
||||
}
|
||||
const sessionId = handleConnectToHost(host);
|
||||
const effectiveHostResolver = alreadyEffective
|
||||
? (candidate: Host) => candidate
|
||||
: resolveEffectiveHost;
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
systemInfoRef,
|
||||
}), host);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host) => {
|
||||
const sessionId = handleConnectToHost(host, true);
|
||||
if (!sessionId) {
|
||||
return { ok: false as const, error: `Failed to open host "${hostId}".` };
|
||||
return { ok: false as const, error: `Failed to open host "${host.id}".` };
|
||||
}
|
||||
// Surface the main window for external MCP / CLI open requests, unless the
|
||||
// user disabled this in Settings → AI → External MCP.
|
||||
if (readExternalMcpFocusOnHostOpen()) {
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
}
|
||||
// Surface the main window for external MCP / CLI open requests.
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost, hosts]);
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||
if (!sessions.some((session) => session.id === sessionId)) {
|
||||
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
||||
}
|
||||
netcattyBridge.get()?.closeSession?.(sessionId);
|
||||
closeSession(sessionId);
|
||||
return { ok: true as const };
|
||||
}, [closeSession, sessions]);
|
||||
|
||||
useVaultAgentBridge({
|
||||
hosts,
|
||||
@@ -1030,17 +1111,26 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
portForwardingRules,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
proxyProfiles,
|
||||
managedSources,
|
||||
terminalSettings,
|
||||
resolveEffectiveHost,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateSnippets,
|
||||
customGroups,
|
||||
updateCustomGroups,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
updateManagedSources,
|
||||
updatePortForwardingRules: importPortForwardingRules,
|
||||
notes,
|
||||
updateNotes,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
@@ -1408,7 +1498,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets: createWorkspaceFromEffectiveTargets, createWorkspaceWithHosts: createWorkspaceWithEffectiveHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1421,19 +1511,23 @@ function AppWithProviders() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// Hide splash screen with a fade-out animation
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out');
|
||||
// Remove from DOM after animation completes
|
||||
setTimeout(() => splash.remove(), 200);
|
||||
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
// Notify main process that renderer is ready
|
||||
netcattyBridge.get()?.rendererReady?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep MCP/SDK approval IPC alive for the whole app lifetime — including the
|
||||
|
||||
+28
-25
@@ -8,37 +8,42 @@
|
||||
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
|
||||
>
|
||||
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
|
||||
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
|
||||
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
|
||||
> Mosh 模板参考(**仅 MoshCatty 纯二进制路径**):`resources/mosh/README.md`、
|
||||
> `scripts/fetch-mosh-binaries.cjs`、`scripts/resolve-mosh-bin-release.cjs`、
|
||||
> `scripts/mosh-extra-resources.cjs`、`electron/bridges/terminalBridge/moshSession.cjs`。
|
||||
> 客户端本体在独立仓库 [binaricat/MoshCatty](https://github.com/binaricat/MoshCatty)
|
||||
> (`moshcatty-*` releases);Netcatty 内已无 Cygwin 构建流水线 / FluentTerminal 回退。
|
||||
|
||||
## 关键设计差异(ET vs Mosh)
|
||||
|
||||
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
|
||||
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
|
||||
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
|
||||
- **协议**:Mosh 需要 Node 做 SSH bootstrap + 抓 `MOSH CONNECT` + 换 PTY
|
||||
(`moshHandshake` + `moshSession`)。**ET 不需要** —— `et` 客户端自己完成 SSH
|
||||
引导 + 协议握手,我们只要把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有**
|
||||
`etHandshake.cjs`。
|
||||
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
|
||||
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
|
||||
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
|
||||
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
|
||||
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
|
||||
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
|
||||
- **纯二进制**:MoshCatty 与理想 ET 打包都是「每平台一个客户端文件」。Mosh 侧已
|
||||
无 terminfo / Cygwin DLL 袋;`et` 同样本地不渲染终端。Windows 若动态链 CRT
|
||||
才考虑可选 DLL 目录,否则只放 `et[.exe]`。
|
||||
- **构建系统**:Mosh 客户端在 **MoshCatty** 仓库用 Rust 构建并发布;Netcatty 只
|
||||
`fetch`。**ET** 用 CMake + Ninja + vcpkg
|
||||
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
|
||||
产物是单个 `et`(Windows `et.exe`)。
|
||||
产物是单个 `et`(Windows `et.exe`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
|
||||
|
||||
## 命名约定(镜像 Mosh)
|
||||
## 命名约定(镜像 Mosh / MoshCatty)
|
||||
|
||||
| Mosh | ET |
|
||||
| Mosh (MoshCatty) | ET |
|
||||
|------|----|
|
||||
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
|
||||
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
|
||||
| `scripts/build-mosh/` | `scripts/build-et/` |
|
||||
| 上游构建:`binaricat/MoshCatty` CI releases | `scripts/build-et/` + `build-et-binaries.yml` |
|
||||
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
|
||||
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
|
||||
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `MoshCatty` / tag `moshcatty-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
|
||||
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
|
||||
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +57,7 @@
|
||||
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
|
||||
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
|
||||
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
|
||||
校验非系统动态库(同 mosh 的 ldd 白名单)。
|
||||
校验非系统动态库(ldd 白名单)。
|
||||
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
|
||||
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
|
||||
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
|
||||
@@ -60,13 +65,12 @@
|
||||
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
|
||||
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
|
||||
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。纯客户端文件为主。
|
||||
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
|
||||
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
|
||||
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
|
||||
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
|
||||
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**。
|
||||
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
|
||||
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
|
||||
(镜像对应 mosh 测试,改名/改路径)。
|
||||
@@ -77,13 +81,12 @@
|
||||
`scripts/*.test.cjs`(确认即可)。
|
||||
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
|
||||
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
|
||||
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
|
||||
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
|
||||
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
|
||||
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 四个构建 job + 一个
|
||||
`release` job(dispatch 且 `release_tag` 非空时发布到 `Netcatty-et-bin`,附
|
||||
`SHA256SUMS`)。`paths` 过滤指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、
|
||||
`scripts/et-extra-resources.cjs`。env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh 的 `MOSH_BIN_RELEASE`)。
|
||||
|
||||
## Phase 2 — 运行时定位捆绑客户端
|
||||
|
||||
@@ -100,7 +103,7 @@
|
||||
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
|
||||
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
|
||||
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
|
||||
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
|
||||
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
|
||||
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
|
||||
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
|
||||
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
|
||||
|
||||
test('batch tab close removes standalone sessions in one state update', async () => {
|
||||
const closedSessionBatches: string[][] = [];
|
||||
const probedSessionBatches: string[][] = [];
|
||||
const closeTabsInFlightRef = { current: false };
|
||||
const sessions = [
|
||||
{ id: 's1', protocol: 'ssh' },
|
||||
{ id: 's2', protocol: 'ssh' },
|
||||
{ id: 's3', protocol: 'ssh' },
|
||||
];
|
||||
|
||||
await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => {},
|
||||
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
|
||||
closeTabsInFlightRef,
|
||||
closeWorkspace: () => {},
|
||||
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
|
||||
probedSessionBatches.push(sessionIds);
|
||||
return true;
|
||||
},
|
||||
logViews: [],
|
||||
sessions,
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1', 's2', 's3'],
|
||||
);
|
||||
|
||||
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.equal(closeTabsInFlightRef.current, false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
flushQueuedTrayPanelConnectHostsImpl,
|
||||
handleConnectToHostImpl,
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
@@ -116,3 +117,151 @@ test('queued tray panel connects flush in order', () => {
|
||||
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
|
||||
assert.deepEqual(pendingHostIds, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit can save login password for the session host', () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'old-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-1',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
const bridgeResponses: unknown[] = [];
|
||||
const hostUpdates: Host[][] = [];
|
||||
|
||||
handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
bridgeResponses.push(args);
|
||||
},
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hostUpdates.push(nextHosts);
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-1',
|
||||
['login-password', 'otp-code'],
|
||||
'new-login-password',
|
||||
);
|
||||
|
||||
assert.equal(hostUpdates.length, 1);
|
||||
assert.deepEqual(hosts[0], {
|
||||
...baseHost,
|
||||
password: 'new-login-password',
|
||||
savePassword: true,
|
||||
});
|
||||
assert.deepEqual(queue, []);
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'login-password',
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-external',
|
||||
sessionId: 'sftp-connection-1',
|
||||
hostId: baseHost.id,
|
||||
scope: 'external',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: false,
|
||||
}];
|
||||
let hostUpdates = 0;
|
||||
|
||||
handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => {},
|
||||
}),
|
||||
},
|
||||
sessions: [],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hostUpdates += 1;
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-external',
|
||||
['secondary-password'],
|
||||
'should-not-save',
|
||||
);
|
||||
|
||||
assert.equal(hostUpdates, 0);
|
||||
assert.equal(hosts[0].password, 'login-password');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit uses explicit hostId when saving password', () => {
|
||||
const jumpHost: Host = {
|
||||
...baseHost,
|
||||
id: 'jump-1',
|
||||
label: 'Jump',
|
||||
hostname: 'jump.example.com',
|
||||
password: 'old-jump-password',
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'target-password',
|
||||
}, jumpHost];
|
||||
let queue = [{
|
||||
requestId: 'ki-jump',
|
||||
sessionId: 'terminal-session-1',
|
||||
hostId: jumpHost.id,
|
||||
scope: 'terminal',
|
||||
hostname: jumpHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
|
||||
handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => {},
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'terminal-session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-jump',
|
||||
['jump-login-password'],
|
||||
'new-jump-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts.find((host) => host.id === baseHost.id)?.password, 'target-password');
|
||||
assert.equal(hosts.find((host) => host.id === jumpHost.id)?.password, 'new-jump-password');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handleTrayTogglePortForwardImpl } from './AppHandlers';
|
||||
|
||||
const rule = { id: 'rule-1', hostId: 'host-1' };
|
||||
const host = { id: 'host-1' };
|
||||
|
||||
function createContext(options: { requestedStart: boolean; hasRuntimeTunnel: boolean }) {
|
||||
const calls = { start: 0, stop: 0 };
|
||||
const context = {
|
||||
hasRuntimeTunnel: () => options.hasRuntimeTunnel,
|
||||
hosts: [host],
|
||||
identities: [],
|
||||
keys: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [rule],
|
||||
resolveEffectiveHost: (value: unknown) => value,
|
||||
startTunnel: () => {
|
||||
calls.start += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
stopTunnel: () => {
|
||||
calls.stop += 1;
|
||||
return Promise.resolve({ success: true });
|
||||
},
|
||||
t: (key: string) => key,
|
||||
terminalSettings: {},
|
||||
toast: { error: () => undefined },
|
||||
};
|
||||
|
||||
handleTrayTogglePortForwardImpl(() => context, rule.id, options.requestedStart);
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('tray ignores a stale start request when the tunnel is already running', () => {
|
||||
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: true });
|
||||
assert.deepEqual(calls, { start: 0, stop: 0 });
|
||||
});
|
||||
|
||||
test('tray starts an inactive rule when no runtime tunnel exists', () => {
|
||||
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: false });
|
||||
assert.deepEqual(calls, { start: 1, stop: 0 });
|
||||
});
|
||||
|
||||
test('tray stop requests remain idempotent', () => {
|
||||
const calls = createContext({ requestedStart: false, hasRuntimeTunnel: false });
|
||||
assert.deepEqual(calls, { start: 0, stop: 1 });
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId:
|
||||
}
|
||||
|
||||
export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId: string, start: boolean) {
|
||||
const { hosts, identities, keys, knownHosts, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
|
||||
const { hasRuntimeTunnel, hosts, identities, keys, knownHosts, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
|
||||
{
|
||||
const rule = portForwardingRules.find((item) => item.id === ruleId);
|
||||
if (!rule) return;
|
||||
@@ -48,14 +48,18 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
}
|
||||
|
||||
if (start) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, knownHosts);
|
||||
if (!hasRuntimeTunnel(ruleId)) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, knownHosts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void stopTunnel(ruleId);
|
||||
void stopTunnel(ruleId).then((result) => {
|
||||
if (!result.success && result.error) toast.error(result.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,27 +231,54 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, requestId: string, responses: string[], savePassword?: string) {
|
||||
const { hosts, keyboardInteractiveQueue, netcattyBridge, sessions, setKeyboardInteractiveQueue, updateHosts } = getCtx();
|
||||
export function handleKeyboardInteractiveSubmitImpl(
|
||||
getCtx: AppContextGetter,
|
||||
requestId: string,
|
||||
responses: string[],
|
||||
savePassword?: string,
|
||||
) {
|
||||
const {
|
||||
hosts,
|
||||
keyboardInteractiveQueue,
|
||||
netcattyBridge,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
updateHosts,
|
||||
} = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Save password to host if requested
|
||||
if (savePassword) {
|
||||
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
||||
if (request?.sessionId) {
|
||||
const session = sessions.find(s => s.id === request.sessionId);
|
||||
// Only save when the prompting hostname matches the session's host,
|
||||
// to avoid overwriting the destination host's password with a jump host's password
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
||||
const session = request?.sessionId
|
||||
? sessions.find(s => s.id === request.sessionId)
|
||||
: undefined;
|
||||
const explicitHostId = typeof request?.hostId === "string" ? request.hostId : undefined;
|
||||
const canUseExplicitHost = !!explicitHostId;
|
||||
// Only mutate the destination host when the prompting hostname matches —
|
||||
// jump-host challenges without an explicit hostId must not rewrite the target host.
|
||||
const canUpdateDestinationHost = !!(
|
||||
session?.hostId
|
||||
&& (!request?.hostname || request.hostname === session.hostname)
|
||||
);
|
||||
const hostIdToUpdate = canUseExplicitHost
|
||||
? explicitHostId
|
||||
: canUpdateDestinationHost
|
||||
? session.hostId
|
||||
: undefined;
|
||||
const host = hostIdToUpdate
|
||||
? hosts.find(h => h.id === hostIdToUpdate)
|
||||
: undefined;
|
||||
// Save password to host if requested - never for second-factor / EDR prompts
|
||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||
// host login password (#2150 / Codex review on #2151).
|
||||
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
} : h));
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
@@ -429,7 +460,7 @@ export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, s
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const { closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
||||
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
||||
{
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
@@ -452,11 +483,15 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
const standaloneSessionIds = targetIds.filter((tabId) => (
|
||||
sessions.some((session) => session.id === tabId)
|
||||
));
|
||||
if (standaloneSessionIds.length > 0) {
|
||||
closeSessions(standaloneSessionIds);
|
||||
}
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onRunSnippet={runSnippet}
|
||||
onOpenLogView={openLogView}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
hostClickBehavior={settings.hostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
|
||||
@@ -12,8 +12,9 @@ type KeyboardInteractiveScope = "terminal" | "external";
|
||||
type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
};
|
||||
type SessionIdLike = { id: string };
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
request: KeyboardInteractiveRequestLike,
|
||||
@@ -26,7 +27,7 @@ export function shouldQueueKeyboardInteractiveRequest(
|
||||
|
||||
export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const {dismissUpdate, enabled = true, groupConfigs, hosts, identities,
|
||||
installUpdate, isVaultInitialized, keys, knownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
hasRuntimeTunnel, installUpdate, isVaultInitialized, keys, knownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
t, terminalSettings, updateState, workspaces,
|
||||
} = ctx;
|
||||
const sessionsRef = useRef(sessions);
|
||||
@@ -145,7 +146,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
portForwardRules: portForwardingRules.map((rule: any) => ({
|
||||
...rule,
|
||||
canStop: hasRuntimeTunnel(rule.id),
|
||||
})),
|
||||
hosts: hostsForSystemMenu,
|
||||
});
|
||||
}, 250);
|
||||
@@ -154,7 +158,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
|
||||
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
@@ -198,11 +202,13 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
hostId: request.hostId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
}]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
clear: () => localStorageValues.clear(),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
planConvergentSyncMigration,
|
||||
stripConvergentSyncEnvelope,
|
||||
} = await import('../domain/convergentSync/index.ts');
|
||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||
const {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
} = await import('./convergentSyncMigration.ts');
|
||||
|
||||
function payload(): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||
const remotePayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'host-1',
|
||||
label: 'Legacy host',
|
||||
hostname: 'legacy.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
getAllProviders: () => ({
|
||||
github: { provider: 'github', status: 'connected' },
|
||||
}),
|
||||
loadConvergentProviderBaseline: async () => null,
|
||||
loadSyncBase: async () => null,
|
||||
downloadFromProvider: async () => ({
|
||||
provider: 'github',
|
||||
payload: remotePayload,
|
||||
remoteFile: {
|
||||
meta: {
|
||||
version: 7,
|
||||
updatedAt: NOW - 1,
|
||||
deviceId: 'legacy-device',
|
||||
deviceName: 'Legacy device',
|
||||
appVersion: '1.0.0',
|
||||
iv: '',
|
||||
salt: '',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
kdfIterations: 1,
|
||||
},
|
||||
payload: 'ciphertext',
|
||||
},
|
||||
}),
|
||||
getState: () => ({ deviceId: 'local-device' }),
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
const prepared = await prepareConvergentSyncMigration(payload(), manager, NOW);
|
||||
|
||||
assert.equal(prepared.plan.preview.canInitialize, true);
|
||||
assert.equal(prepared.providerBaselines.length, 1);
|
||||
const baseline = prepared.providerBaselines[0]!;
|
||||
assert.equal(baseline.provider, 'github');
|
||||
assert.equal(baseline.remoteVersion, 7);
|
||||
assert.equal(baseline.remoteDeviceId, 'legacy-device');
|
||||
assert.deepEqual(baseline.materializedPayload, remotePayload);
|
||||
assert.deepEqual(baseline.state, prepared.plan.state);
|
||||
});
|
||||
|
||||
test('initialization applies the protected preview before persisting and enabling the replica', async () => {
|
||||
const localPayload = payload();
|
||||
const liveLocalPayload = { ...payload(), knownHosts: [] };
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
assert.equal(plan.preview.canInitialize, true);
|
||||
const calls: string[] = [];
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => {
|
||||
calls.push('lock');
|
||||
return task();
|
||||
},
|
||||
saveConvergentReplica: async () => {
|
||||
calls.push('replica');
|
||||
},
|
||||
saveConvergentProviderBaseline: async () => {
|
||||
calls.push('baseline');
|
||||
},
|
||||
syncConvergentProvidersUnderLock: async (incoming: SyncPayload) => {
|
||||
calls.push('publish');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
return new Map();
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
now: NOW,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => {
|
||||
calls.push('snapshot');
|
||||
return liveLocalPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
calls.push('apply');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
calls.push('protect');
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
const apply = await options.prepareApply();
|
||||
assert.equal(options.buildPreApplyPayload(), liveLocalPayload);
|
||||
await apply();
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['lock', 'protect', 'snapshot', 'apply', 'replica', 'publish']);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: true, initialized: true });
|
||||
});
|
||||
|
||||
test('initialization applies a concurrent provider merge before releasing the migration lock', async () => {
|
||||
const localPayload = payload();
|
||||
const mergedPayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'remote-host',
|
||||
label: 'Remote host',
|
||||
hostname: 'remote.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
const applied: SyncPayload[] = [];
|
||||
let currentPayload = localPayload;
|
||||
let lockHeld = false;
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => {
|
||||
lockHeld = true;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
lockHeld = false;
|
||||
}
|
||||
},
|
||||
saveConvergentReplica: async () => {},
|
||||
saveConvergentProviderBaseline: async () => {},
|
||||
syncConvergentProvidersUnderLock: async (_incoming, applyPayload) => {
|
||||
assert.equal(lockHeld, true);
|
||||
await applyPayload(mergedPayload, async () => {});
|
||||
return new Map([[
|
||||
'github',
|
||||
{
|
||||
success: true,
|
||||
provider: 'github',
|
||||
action: 'merge',
|
||||
mergedPayload,
|
||||
mergedPayloadApplied: true,
|
||||
},
|
||||
]]);
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
now: NOW,
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => currentPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
assert.equal(lockHeld, true);
|
||||
applied.push(incoming);
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
const apply = await options.prepareApply();
|
||||
await apply();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(applied.length, 2);
|
||||
assert.equal(applied[0]?.convergentSync?.schemaVersion, 2);
|
||||
assert.equal(applied[1]?.hosts[0]?.label, 'Remote host');
|
||||
assert.equal(lockHeld, false);
|
||||
});
|
||||
|
||||
test('initialization rejects a stale preview before backup or apply', async () => {
|
||||
const localPayload = payload();
|
||||
const changedPayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'host-after-preview',
|
||||
label: 'Added after preview',
|
||||
hostname: 'new.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let protectedApplyEntered = false;
|
||||
let applied = false;
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
buildCurrentPayload: () => changedPayload,
|
||||
buildPreApplyPayload: () => changedPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
protectedApplyEntered = true;
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
await options.prepareApply();
|
||||
},
|
||||
}),
|
||||
/changed after the migration preview/i,
|
||||
);
|
||||
|
||||
assert.equal(protectedApplyEntered, true);
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
test('blocked previews cannot enter the protected initialization transaction', async () => {
|
||||
const localPayload = payload();
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [{ provider: 'github', status: 'unavailable', message: 'offline' }],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let entered = false;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager: {} as CloudSyncManager,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => localPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
}),
|
||||
/migration is blocked/,
|
||||
);
|
||||
assert.equal(entered, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
test('a locked manager cannot enter the protected initialization transaction', async () => {
|
||||
const localPayload = payload();
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let entered = false;
|
||||
let applied = false;
|
||||
let snapshotBuilt = false;
|
||||
const manager = {
|
||||
isUnlocked: () => false,
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => {
|
||||
snapshotBuilt = true;
|
||||
return localPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
}),
|
||||
/Unlock cloud sync before initializing convergent migration/,
|
||||
);
|
||||
|
||||
assert.equal(entered, false);
|
||||
assert.equal(snapshotBuilt, false);
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConvergentMigrationPreview,
|
||||
ConvergentProviderBaselineV2,
|
||||
SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
cloudSyncPayloadsEqual,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
planConvergentSyncMigration,
|
||||
stripConvergentSyncEnvelope,
|
||||
validateConvergentSyncPayload,
|
||||
type ConvergentMigrationPlan,
|
||||
type ConvergentMigrationProviderInput,
|
||||
} from '../domain/convergentSync';
|
||||
import { isProviderReadyForSync } from '../domain/sync';
|
||||
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
|
||||
import { applyProtectedSyncPayload } from './localVaultBackups';
|
||||
|
||||
export interface PreparedConvergentMigration {
|
||||
plan: ConvergentMigrationPlan;
|
||||
providerBaselines: ConvergentProviderBaselineV2[];
|
||||
localSnapshot: SyncPayload;
|
||||
}
|
||||
|
||||
interface LegacyProviderBaselineSeed {
|
||||
provider: CloudProvider;
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
remoteDeviceId: string;
|
||||
materializedPayload: SyncPayload;
|
||||
}
|
||||
|
||||
function selectLocalTrustedBaseline(baselines: SyncPayload[]): SyncPayload | null {
|
||||
if (baselines.length === 0) return null;
|
||||
const first = baselines[0];
|
||||
return baselines.every((baseline) => cloudSyncPayloadsEqual(first, baseline))
|
||||
? first
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function prepareConvergentSyncMigration(
|
||||
localPayload: SyncPayload,
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): Promise<PreparedConvergentMigration> {
|
||||
if (!manager.isUnlocked()) throw new Error('Unlock cloud sync before preparing migration');
|
||||
const providers = (Object.entries(manager.getAllProviders()) as Array<[
|
||||
CloudProvider,
|
||||
ReturnType<CloudSyncManager['getProviderConnection']>,
|
||||
]>)
|
||||
.filter(([, connection]) => isProviderReadyForSync(connection))
|
||||
.map(([provider]) => provider)
|
||||
.sort();
|
||||
const baselineByProvider = new Map<CloudProvider, SyncPayload | null>();
|
||||
const providerBaselines: ConvergentProviderBaselineV2[] = [];
|
||||
const legacyBaselineSeeds: LegacyProviderBaselineSeed[] = [];
|
||||
const inputs: ConvergentMigrationProviderInput[] = await Promise.all(
|
||||
providers.map(async (provider): Promise<ConvergentMigrationProviderInput> => {
|
||||
try {
|
||||
const convergentBaseline = await manager.loadConvergentProviderBaseline(provider);
|
||||
const baseline = convergentBaseline?.materializedPayload
|
||||
?? await manager.loadSyncBase(provider);
|
||||
baselineByProvider.set(provider, baseline);
|
||||
const remote = await manager.downloadFromProvider(provider);
|
||||
if (!remote) return { provider, status: 'empty' };
|
||||
const remoteState = validateConvergentSyncPayload(remote.remoteFile.meta, remote.payload);
|
||||
if (remoteState) {
|
||||
providerBaselines.push({
|
||||
schemaVersion: 2,
|
||||
provider,
|
||||
remoteVersion: remote.remoteFile.meta.version,
|
||||
remoteUpdatedAt: remote.remoteFile.meta.updatedAt,
|
||||
remoteDeviceId: remote.remoteFile.meta.deviceId,
|
||||
materializedPayload: stripConvergentSyncEnvelope(remote.payload),
|
||||
state: remoteState,
|
||||
});
|
||||
} else {
|
||||
legacyBaselineSeeds.push({
|
||||
provider,
|
||||
remoteVersion: remote.remoteFile.meta.version,
|
||||
remoteUpdatedAt: remote.remoteFile.meta.updatedAt,
|
||||
remoteDeviceId: remote.remoteFile.meta.deviceId,
|
||||
materializedPayload: stripConvergentSyncEnvelope(remote.payload),
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
status: 'ready',
|
||||
meta: remote.remoteFile.meta,
|
||||
payload: remote.payload,
|
||||
trustedBaseline: baseline,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
provider,
|
||||
status: 'unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
const localTrustedBaseline = selectLocalTrustedBaseline(
|
||||
[...baselineByProvider.values()].filter((value): value is SyncPayload => value !== null),
|
||||
);
|
||||
const localSnapshot = JSON.parse(JSON.stringify(
|
||||
stripConvergentSyncEnvelope(localPayload),
|
||||
)) as SyncPayload;
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload: localSnapshot,
|
||||
localTrustedBaseline,
|
||||
providers: inputs,
|
||||
deviceId: manager.getState().deviceId,
|
||||
now,
|
||||
});
|
||||
if (plan.state) {
|
||||
for (const seed of legacyBaselineSeeds) {
|
||||
providerBaselines.push({
|
||||
schemaVersion: 2,
|
||||
...seed,
|
||||
// The canonical migration state already incorporates this exact v1
|
||||
// remote. Keeping its original materialized snapshot lets a later
|
||||
// legacy write become a field diff without blocking the first v2 upload.
|
||||
state: plan.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
providerBaselines: providerBaselines.sort((left, right) => left.provider.localeCompare(right.provider)),
|
||||
localSnapshot,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializePreparedConvergentMigration(options: {
|
||||
prepared: PreparedConvergentMigration;
|
||||
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
manager?: CloudSyncManager;
|
||||
now?: number;
|
||||
runProtectedApply?: typeof applyProtectedSyncPayload;
|
||||
}): Promise<ConvergentMigrationPreview> {
|
||||
const { prepared } = options;
|
||||
const manager = options.manager ?? getCloudSyncManager();
|
||||
const now = options.now ?? Date.now();
|
||||
if (!prepared.plan.preview.canInitialize || !prepared.plan.state || !prepared.plan.payload) {
|
||||
throw new Error(`Convergent migration is blocked: ${prepared.plan.preview.blockedReasons.join('; ')}`);
|
||||
}
|
||||
if (!manager.isUnlocked()) {
|
||||
throw new Error('Unlock cloud sync before initializing convergent migration');
|
||||
}
|
||||
const runProtectedApply = options.runProtectedApply ?? applyProtectedSyncPayload;
|
||||
await manager.withConvergentSyncLock(async () => {
|
||||
await runProtectedApply({
|
||||
buildPreApplyPayload: options.buildPreApplyPayload,
|
||||
translateProtectiveBackupFailure: options.translateProtectiveBackupFailure,
|
||||
prepareApply: async () => {
|
||||
const currentPayload = stripConvergentSyncEnvelope(await options.buildCurrentPayload());
|
||||
if (!cloudSyncPayloadsEqual(prepared.localSnapshot, currentPayload)) {
|
||||
throw new Error(
|
||||
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
await options.applyPayload(prepared.plan.payload as SyncPayload);
|
||||
for (const baseline of prepared.providerBaselines) {
|
||||
await manager.saveConvergentProviderBaseline(baseline);
|
||||
}
|
||||
await manager.saveConvergentReplica({
|
||||
schemaVersion: 2,
|
||||
state: prepared.plan.state as NonNullable<ConvergentMigrationPlan['state']>,
|
||||
updatedAt: now,
|
||||
});
|
||||
markConvergentSyncInitialized();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The materialized v1 snapshot often stays byte-for-byte unchanged, so
|
||||
// hash-driven auto-sync cannot be trusted to publish the new envelope.
|
||||
// Force the first v2 read/merge/write/verify cycle before releasing the
|
||||
// same Web Lock used by initialization.
|
||||
const publishResults = await manager.syncConvergentProvidersUnderLock(
|
||||
prepared.plan.payload as SyncPayload,
|
||||
async (mergedPayload, commitReplica) => runProtectedApply({
|
||||
buildPreApplyPayload: options.buildPreApplyPayload,
|
||||
translateProtectiveBackupFailure: options.translateProtectiveBackupFailure,
|
||||
prepareApply: async () => {
|
||||
const currentPayload = stripConvergentSyncEnvelope(await options.buildCurrentPayload());
|
||||
if (!cloudSyncPayloadsEqual(prepared.plan.payload as SyncPayload, currentPayload)) {
|
||||
throw new Error(
|
||||
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
await options.applyPayload(mergedPayload);
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = [...publishResults.values()].find((result) => !result.success);
|
||||
if (failed) {
|
||||
throw new Error(
|
||||
`Convergent migration could not publish to ${failed.provider}: ${failed.error ?? 'sync failed'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return prepared.plan.preview;
|
||||
}
|
||||
|
||||
export async function prepareConvergentSyncDowngrade(
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): Promise<SyncPayload> {
|
||||
const replica = await manager.loadConvergentReplica();
|
||||
if (!replica) throw new Error('No convergent sync replica is available to downgrade');
|
||||
return materializeSyncPayloadFromConvergentState(replica.state, { syncedAt: now });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createConvergentSyncStateFromPayload,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
} from '../domain/convergentSync/index.ts';
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica.ts';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function payload(label: string): SyncPayload {
|
||||
return {
|
||||
hosts: [{
|
||||
id: 'host-1',
|
||||
label,
|
||||
hostname: 'example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
test('local restore is recorded as writes on the active replica instead of replacing it', async () => {
|
||||
const state = createConvergentSyncStateFromPayload(payload('Before'), 'seed', NOW);
|
||||
let savedState = state;
|
||||
let saveCount = 0;
|
||||
const manager = {
|
||||
loadConvergentReplica: async () => ({ schemaVersion: 2 as const, state, updatedAt: NOW }),
|
||||
getState: () => ({ deviceId: 'local-device' }),
|
||||
saveConvergentReplica: async (record: { state: typeof state }) => {
|
||||
saveCount += 1;
|
||||
savedState = record.state;
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
const commit = await prepareRestoredPayloadConvergentWrites(
|
||||
payload('Restored'),
|
||||
NOW + 1,
|
||||
{ manager, initialized: true },
|
||||
);
|
||||
assert.equal(saveCount, 0);
|
||||
|
||||
await commit();
|
||||
|
||||
const materialized = materializeSyncPayloadFromConvergentState(savedState, { syncedAt: NOW + 1 });
|
||||
assert.equal(saveCount, 1);
|
||||
assert.equal(materialized.hosts[0].label, 'Restored');
|
||||
assert.equal(savedState.vector['local-device'] > 0, true);
|
||||
assert.equal(savedState.vector.seed > 0, true);
|
||||
});
|
||||
|
||||
test('an initialized configuration fails closed when its active replica is missing', async () => {
|
||||
const manager = {
|
||||
loadConvergentReplica: async () => null,
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => prepareRestoredPayloadConvergentWrites(
|
||||
payload('Restored'),
|
||||
NOW,
|
||||
{ manager, initialized: true },
|
||||
),
|
||||
/local replica is missing/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
applyLegacySyncPayload,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
stripConvergentSyncEnvelope,
|
||||
} from '../domain/convergentSync';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { getConvergentSyncLocalConfig } from '../infrastructure/services/convergentSyncConfig';
|
||||
|
||||
export type CommitRestoredPayloadConvergentWrites = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Local backups intentionally contain no active CRDT replica. Before applying
|
||||
* a restore, validate the active replica and prepare ordinary local writes.
|
||||
* The returned commit persists them only after the local import succeeds, so
|
||||
* the replica never claims a restore that the local import rejected.
|
||||
*/
|
||||
export async function prepareRestoredPayloadConvergentWrites(
|
||||
restoredPayload: SyncPayload,
|
||||
now = Date.now(),
|
||||
dependencies: {
|
||||
manager?: CloudSyncManager;
|
||||
initialized?: boolean;
|
||||
} = {},
|
||||
): Promise<CommitRestoredPayloadConvergentWrites> {
|
||||
const initialized = dependencies.initialized
|
||||
?? getConvergentSyncLocalConfig().initialized;
|
||||
if (!initialized) return async () => {};
|
||||
const manager = dependencies.manager ?? getCloudSyncManager();
|
||||
const replica = await manager.loadConvergentReplica();
|
||||
if (!replica) {
|
||||
throw new Error('Convergent sync is initialized but its local replica is missing');
|
||||
}
|
||||
const baseline = materializeSyncPayloadFromConvergentState(replica.state, {
|
||||
syncedAt: replica.updatedAt,
|
||||
});
|
||||
const state = applyLegacySyncPayload(
|
||||
replica.state,
|
||||
baseline,
|
||||
stripConvergentSyncEnvelope(restoredPayload),
|
||||
manager.getState().deviceId,
|
||||
now,
|
||||
);
|
||||
return () => manager.saveConvergentReplica({ schemaVersion: 2, state, updatedAt: now });
|
||||
}
|
||||
@@ -3,32 +3,155 @@ import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
|
||||
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
|
||||
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
|
||||
|
||||
function defaultKeyPassphrasePathKey(keyPath: string): string {
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^[\\/]{2}/u.test(keyPath);
|
||||
if (!isWindowsPath) return keyPath;
|
||||
const normalized = keyPath.replace(/\\/g, "/");
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function matchingPathKeys(keyPaths: string[]): Set<string> {
|
||||
return new Set(keyPaths.map(defaultKeyPassphrasePathKey));
|
||||
}
|
||||
|
||||
export async function resolveDefaultKeyPassphraseAliases(keyPath: string): Promise<string[]> {
|
||||
const aliases = new Set([keyPath]);
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^\\\\/u.test(keyPath);
|
||||
const normalizedKeyPath = isWindowsPath ? keyPath.replace(/\\/g, "/") : keyPath;
|
||||
aliases.add(normalizedKeyPath);
|
||||
try {
|
||||
const homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!homeDir) return [...aliases];
|
||||
|
||||
const normalizedHome = homeDir.replace(/\\/g, "/").replace(/\/$/u, "");
|
||||
const comparableHome = defaultKeyPassphrasePathKey(normalizedHome);
|
||||
const comparableKeyPath = defaultKeyPassphrasePathKey(normalizedKeyPath);
|
||||
if (comparableKeyPath.startsWith(`${comparableHome}/`)) {
|
||||
aliases.add(`~/${normalizedKeyPath.slice(normalizedHome.length + 1)}`);
|
||||
} else if (normalizedKeyPath.startsWith("~/")) {
|
||||
const suffix = normalizedKeyPath.slice(2);
|
||||
aliases.add(`${normalizedHome}/${suffix}`);
|
||||
const nativeHome = homeDir.replace(/[\\/]+$/u, "");
|
||||
const nativeSeparator = homeDir.includes("\\") ? "\\" : "/";
|
||||
aliases.add(`${nativeHome}${nativeSeparator}${suffix.replace(/\//g, nativeSeparator)}`);
|
||||
}
|
||||
} catch {
|
||||
// The renderer bridge may be unavailable in tests or web fallback mode.
|
||||
}
|
||||
return [...aliases];
|
||||
}
|
||||
|
||||
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const encrypted = await encryptField(passphrase) ?? passphrase;
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
||||
store[keyPath] = await encryptField(passphrase) ?? passphrase;
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
for (const storedPath of Object.keys(store)) {
|
||||
if (storedPath !== keyPath && aliasKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
||||
delete store[storedPath];
|
||||
}
|
||||
}
|
||||
store[keyPath] = encrypted;
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
||||
}
|
||||
|
||||
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
const enc = store?.[keyPath];
|
||||
if (!enc) return null;
|
||||
const decrypted = await decryptField(enc);
|
||||
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
removeDefaultKeyPassphrases([keyPath]);
|
||||
return null;
|
||||
function matchingStoreEntriesChanged(
|
||||
previous: Record<string, string>,
|
||||
latest: Record<string, string> | null,
|
||||
aliasKeys: Set<string>,
|
||||
): boolean {
|
||||
const paths = new Set([
|
||||
...Object.keys(previous),
|
||||
...Object.keys(latest ?? {}),
|
||||
]);
|
||||
for (const path of paths) {
|
||||
if (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
&& previous[path] !== latest?.[path]
|
||||
) return true;
|
||||
}
|
||||
return decrypted;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadDefaultKeyPassphraseOnce(keyPath: string): Promise<{
|
||||
retry: boolean;
|
||||
value: string | null;
|
||||
}> {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) return { retry: false, value: null };
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const storedPaths = Object.keys(store).filter((path) => (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
));
|
||||
const exactIndex = storedPaths.indexOf(keyPath);
|
||||
if (exactIndex > 0) {
|
||||
storedPaths.unshift(storedPaths.splice(exactIndex, 1)[0]);
|
||||
}
|
||||
|
||||
const invalidEntries = new Map<string, string>();
|
||||
for (const storedPath of storedPaths) {
|
||||
const decrypted = await decryptField(store[storedPath]);
|
||||
if (decrypted && !isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
||||
return { retry: true, value: null };
|
||||
}
|
||||
if (latestStore) {
|
||||
let changed = false;
|
||||
for (const duplicatePath of storedPaths) {
|
||||
if (duplicatePath !== storedPath && duplicatePath in latestStore) {
|
||||
delete latestStore[duplicatePath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
||||
}
|
||||
}
|
||||
return { retry: false, value: decrypted };
|
||||
}
|
||||
invalidEntries.set(storedPath, store[storedPath]);
|
||||
}
|
||||
if (invalidEntries.size > 0) {
|
||||
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
||||
return { retry: true, value: null };
|
||||
}
|
||||
if (latestStore) {
|
||||
let changed = false;
|
||||
for (const [storedPath, invalidValue] of invalidEntries) {
|
||||
if (latestStore[storedPath] === invalidValue) {
|
||||
delete latestStore[storedPath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { retry: false, value: null };
|
||||
}
|
||||
|
||||
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const result = await loadDefaultKeyPassphraseOnce(keyPath);
|
||||
if (!result.retry) return result.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) return;
|
||||
const pathKeys = matchingPathKeys(keyPaths);
|
||||
let changed = false;
|
||||
for (const keyPath of keyPaths) {
|
||||
if (keyPath in store) {
|
||||
delete store[keyPath];
|
||||
for (const storedPath of Object.keys(store)) {
|
||||
if (pathKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
||||
delete store[storedPath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -37,10 +160,44 @@ export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDefaultKeyPassphraseAliases(keyPaths: string[]): Promise<string[]> {
|
||||
const before = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
||||
const aliases = Array.from(new Set((await Promise.all(
|
||||
keyPaths.map(resolveDefaultKeyPassphraseAliases),
|
||||
)).flat()));
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const latest = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
||||
const matchingPaths = new Set([...Object.keys(before), ...Object.keys(latest)].filter((path) => (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
)));
|
||||
for (const path of matchingPaths) {
|
||||
if (before[path] !== latest[path]) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
let changed = false;
|
||||
for (const path of matchingPaths) {
|
||||
if (path in latest) {
|
||||
delete latest[path];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latest);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
|
||||
const pathKeys = matchingPathKeys(keyPaths);
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
|
||||
if (
|
||||
key.source === "reference"
|
||||
&& key.filePath
|
||||
&& pathKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
&& key.passphrase
|
||||
) {
|
||||
changed = true;
|
||||
return { ...key, passphrase: undefined, savePassphrase: false };
|
||||
}
|
||||
@@ -78,16 +235,21 @@ export async function rememberKeyPassphrase(args: {
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
await saveDefaultKeyPassphrase(keyPath, passphrase);
|
||||
|
||||
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
|
||||
if (!refKey) return;
|
||||
|
||||
const updated = keys.map((key) =>
|
||||
key.id === refKey.id
|
||||
? { ...key, passphrase, savePassphrase: true }
|
||||
: key
|
||||
);
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (
|
||||
key.source !== "reference"
|
||||
|| !key.filePath
|
||||
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
) return key;
|
||||
changed = true;
|
||||
return { ...key, passphrase, savePassphrase: true };
|
||||
});
|
||||
if (!changed) return;
|
||||
setCurrentKeys?.(updated);
|
||||
await updateKeys(updated);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
const keys = [
|
||||
'cloudSync.convergent.title',
|
||||
'cloudSync.convergent.experimental',
|
||||
'cloudSync.convergent.desc',
|
||||
'cloudSync.convergent.active',
|
||||
'cloudSync.convergent.paused',
|
||||
'cloudSync.convergent.enabled',
|
||||
'cloudSync.convergent.preview.title',
|
||||
'cloudSync.convergent.preview.entities',
|
||||
'cloudSync.convergent.preview.providers',
|
||||
'cloudSync.convergent.preview.conflicts',
|
||||
'cloudSync.convergent.preview.compatibility',
|
||||
'cloudSync.convergent.preview.confirm',
|
||||
'cloudSync.convergent.preview.status.ready',
|
||||
'cloudSync.convergent.preview.status.empty',
|
||||
'cloudSync.convergent.preview.status.unavailable',
|
||||
'cloudSync.convergent.preview.status.blocked',
|
||||
'cloudSync.convergent.preview.schema',
|
||||
'cloudSync.convergent.field.presence',
|
||||
'cloudSync.convergent.field.position',
|
||||
'cloudSync.convergent.conflicts.title',
|
||||
'cloudSync.convergent.conflict.empty',
|
||||
'cloudSync.convergent.conflict.secretSet',
|
||||
'cloudSync.convergent.conflict.current',
|
||||
'cloudSync.convergent.conflict.choose',
|
||||
'cloudSync.convergent.conflict.resolved',
|
||||
'cloudSync.convergent.downgrade.desc',
|
||||
'cloudSync.convergent.downgrade.button',
|
||||
'cloudSync.convergent.downgrade.confirm',
|
||||
'cloudSync.convergent.downgrade.done',
|
||||
] as const;
|
||||
|
||||
test('convergent sync copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const STEER_KEYS = [
|
||||
'ai.codex.steer.addInstruction',
|
||||
'ai.codex.steer.sending',
|
||||
'ai.codex.steer.placeholder',
|
||||
'ai.codex.steer.notSteerableReview',
|
||||
'ai.codex.steer.notSteerableCompact',
|
||||
'ai.codex.steer.busy',
|
||||
'ai.codex.steer.inactive',
|
||||
'ai.codex.steer.unsupported',
|
||||
'ai.codex.steer.failed',
|
||||
] as const;
|
||||
|
||||
test('Codex steering UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = STEER_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,21 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.chat.activity.title': 'Agent activity',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
'ai.chat.activity.webSearch': 'Web search',
|
||||
'ai.chat.activity.fileChanges': 'File changes',
|
||||
'ai.chat.activity.status.running': 'Running',
|
||||
'ai.chat.activity.status.completed': 'Completed',
|
||||
'ai.chat.activity.status.failed': 'Failed',
|
||||
'ai.chat.activity.file.add': 'Add',
|
||||
'ai.chat.activity.file.update': 'Update',
|
||||
'ai.chat.activity.file.delete': 'Delete',
|
||||
'ai.chat.activity.usage': 'Tokens',
|
||||
'ai.chat.activity.usage.input': 'in',
|
||||
'ai.chat.activity.usage.output': 'out',
|
||||
'ai.chat.activity.usage.cached': 'cached',
|
||||
'ai.chat.activity.usage.reasoning': 'reasoning',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
@@ -60,6 +75,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.appServer.title': 'Use Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Experimental',
|
||||
'ai.codex.appServer.description': 'Use the persistent Codex protocol for native approvals, sandbox controls, live models, and mid-turn questions. SDK remains the default.',
|
||||
'ai.codex.appServer.checking': 'Checking App Server support…',
|
||||
'ai.codex.appServer.available': 'App Server is available for this Codex CLI.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'The live Codex model catalog is unavailable. Using the built-in model list.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Allow for session',
|
||||
'ai.codex.appServer.userInput.title': 'Codex needs your input',
|
||||
'ai.codex.appServer.userInput.description': 'Answer these questions to continue the current turn.',
|
||||
'ai.codex.appServer.userInput.other': 'Enter another answer',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Codex will continue automatically if no answer is provided in time.',
|
||||
'ai.codex.appServer.userInput.skip': 'Skip',
|
||||
'ai.codex.appServer.userInput.submit': 'Continue',
|
||||
'ai.codex.steer.addInstruction': 'Add instruction',
|
||||
'ai.codex.steer.sending': 'Adding instruction…',
|
||||
'ai.codex.steer.placeholder': 'Add an instruction while Codex is working…',
|
||||
'ai.codex.steer.notSteerableReview': 'This Codex review turn cannot accept additional instructions. Your draft was kept.',
|
||||
'ai.codex.steer.notSteerableCompact': 'This Codex compaction turn cannot accept additional instructions. Your draft was kept.',
|
||||
'ai.codex.steer.busy': 'Another instruction is already being sent to Codex.',
|
||||
'ai.codex.steer.inactive': 'The Codex turn has already ended. Your draft was kept.',
|
||||
'ai.codex.steer.unsupported': 'In-flight instructions require the Codex App Server runtime.',
|
||||
'ai.codex.steer.failed': 'Codex could not accept the additional instruction. Your draft was kept.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
@@ -197,6 +234,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout': 'Idle timeout',
|
||||
'ai.externalMcp.idleTimeout.description': 'In temporary mode, disable External MCP after this many minutes with no MCP operations.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Automatically close sessions opened by an AI after this many minutes without terminal or file activity.',
|
||||
'ai.externalMcp.usage.title': 'How to use',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Turn on External MCP and keep Netcatty running.',
|
||||
'ai.externalMcp.usage.localhost': '2. Clients connect via the local launcher (127.0.0.1 only). Discovery is removed when you disable the switch.',
|
||||
|
||||
@@ -40,6 +40,14 @@ export const enCoreMessages: Messages = {
|
||||
'common.left': 'Left',
|
||||
'common.right': 'Right',
|
||||
'common.more': 'More',
|
||||
// Dense toolbar customize (show / collapse / hide)
|
||||
'toolbar.layout.customize': 'Customize toolbar',
|
||||
'toolbar.layout.show': 'Show',
|
||||
'toolbar.layout.collapse': 'Collapse',
|
||||
'toolbar.layout.hide': 'Hide',
|
||||
'toolbar.layout.moveEarlier': 'Move earlier',
|
||||
'toolbar.layout.moveLater': 'Move later',
|
||||
'toolbar.layout.reset': 'Reset to default',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.manual': 'Manual order',
|
||||
@@ -248,6 +256,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
'settings.vault.selectBeforeConnect': 'Select host before connecting',
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
@@ -293,7 +303,110 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
|
||||
'settings.appearance.customCss.help.title': 'Custom CSS guide',
|
||||
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
|
||||
|
||||
### How to select
|
||||
|
||||
Use attribute selectors:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-side-panel"] {
|
||||
border: 2px solid #00c851 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Some rows and panes also support state hooks such as \`[data-selected="true"]\` or \`[data-focused="true"]\`.
|
||||
|
||||
Because many styles come from Tailwind utilities, you often need \`!important\` to override them.
|
||||
|
||||
### Top tabs
|
||||
|
||||
- \`top-tabs\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
- \`vault-sidebar\`
|
||||
- \`vault-main\`
|
||||
- \`vault-hosts-header\`
|
||||
- \`vault-host-list\`
|
||||
|
||||
### Terminal workspace
|
||||
|
||||
- \`terminal-workspace\`
|
||||
- \`terminal-workspace-sidebar\` — Focus-mode terminal list
|
||||
- \`terminal-host-tree-sidebar\`
|
||||
- \`terminal-host-tree-sidebar-content\`
|
||||
- \`terminal-host-tree-sidebar-row\`
|
||||
- \`terminal-split-pane\`
|
||||
- \`terminal-split-resizer\`
|
||||
|
||||
### Terminal side panel
|
||||
|
||||
- \`terminal-side-panel\` — SFTP / Scripts / Theme / AI panel (only present while open)
|
||||
- \`terminal-side-panel-tabs\`
|
||||
- \`terminal-side-panel-content\`
|
||||
|
||||
### SFTP
|
||||
|
||||
- \`terminal-sftp-panel\`
|
||||
- \`terminal-sftp-host-header\`
|
||||
- \`terminal-sftp-pane\`
|
||||
- \`terminal-sftp-toolbar\`
|
||||
- \`terminal-sftp-path\`
|
||||
- \`terminal-sftp-filter-bar\`
|
||||
- \`terminal-sftp-list\`
|
||||
- \`terminal-sftp-list-header\`
|
||||
- \`terminal-sftp-list-row\`
|
||||
- \`terminal-sftp-tree\`
|
||||
- \`terminal-sftp-tree-row\`
|
||||
- \`terminal-sftp-transfer-queue\`
|
||||
- \`terminal-sftp-transfer-row\`
|
||||
|
||||
### Detail / tool panels
|
||||
|
||||
- \`snippets-panel\`
|
||||
- \`host-details-panel\`
|
||||
- \`group-details-panel\`
|
||||
- \`serial-host-details-panel\`
|
||||
- \`ai-chat-panel\`
|
||||
|
||||
### Examples
|
||||
|
||||
Hide the host-list toggle in the top tab bar:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="top-tabs-host-tree-toggle"] {
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Style selected SFTP file rows:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-sftp-list-row"][data-selected="true"] {
|
||||
background-color: #00c851 !important;
|
||||
color: #001b10 !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Highlight the focused split pane:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-split-pane"][data-focused="true"] {
|
||||
outline: 2px solid hsl(var(--primary)) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
@@ -352,6 +465,16 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
|
||||
'settings.terminal.font.cjk.option.recommended': 'Recommended for terminal alignment',
|
||||
'settings.terminal.font.cjk.option.installed': 'Installed font',
|
||||
'settings.terminal.font.cjk.option.unverified': 'Availability could not be checked',
|
||||
'settings.terminal.font.cjk.option.unavailable': 'Not installed on this device',
|
||||
'settings.terminal.font.cjk.searchPlaceholder': 'Search installed fonts…',
|
||||
'settings.terminal.font.cjk.empty': 'No matching installed fonts',
|
||||
'settings.terminal.font.cjk.useCustom': 'Use font',
|
||||
'settings.terminal.font.cjk.refresh': 'Refresh installed fonts',
|
||||
'settings.terminal.font.cjk.alignmentWarning': 'This font is not verified as monospaced. Check the preview for column alignment.',
|
||||
'settings.terminal.font.cjk.unavailableWarning': 'This font is not installed on this device. Netcatty will keep the setting and use a safe fallback.',
|
||||
'settings.terminal.font.size': 'Font size',
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
@@ -404,6 +527,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy': 'Normalize terminal text on copy',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy.desc':
|
||||
'When copying from the terminal, strip display-only trailing padding and join soft-wrapped rows so pasted text matches logical output. Turn off to copy the raw screen selection (including TUI padding and visual wraps).',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
@@ -522,6 +648,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
@@ -571,6 +699,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
|
||||
|
||||
// Settings > Terminal > Password prompt assist (sudo/su)
|
||||
'settings.terminal.section.passwordPromptAssist': 'Password prompt assist',
|
||||
'settings.terminal.passwordPromptAssist.mode': 'Assist mode',
|
||||
'settings.terminal.passwordPromptAssist.mode.desc':
|
||||
'When sudo or su asks for a password, offer a saved credential. Never auto-sends without confirmation.',
|
||||
'settings.terminal.passwordPromptAssist.off': 'Off',
|
||||
'settings.terminal.passwordPromptAssist.hint': 'Quick fill (Enter)',
|
||||
'settings.terminal.passwordPromptAssist.picker': 'Credential picker',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': 'System',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latency',
|
||||
'systemManager.overview.latency': 'SSH network latency',
|
||||
'systemManager.overview.cpuCores': 'CPU cores',
|
||||
'systemManager.overview.disks': 'Disks',
|
||||
'systemManager.overview.interfaces': 'Network interfaces',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
@@ -55,6 +57,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.dragPane': 'Drag terminal pane',
|
||||
'terminal.toolbar.showActions': 'Show terminal actions',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -85,10 +89,10 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.disk': 'Disk Usage',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.latency': 'Latency',
|
||||
'terminal.serverStats.latency': 'SSH network latency',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
@@ -338,6 +342,35 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
'cloudSync.strategy.preferLocal': 'Local wins',
|
||||
'cloudSync.strategy.preferLocalDesc': 'When both sides changed, upload the local version and replace cloud changes.',
|
||||
'cloudSync.convergent.title': 'Convergent multi-device sync',
|
||||
'cloudSync.convergent.experimental': 'Experimental',
|
||||
'cloudSync.convergent.desc': 'Uses an encrypted CRDT replica to preserve offline edits, concurrent deletions, and changes from every connected provider.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 is active. Provider writes are verified after upload.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 is paused on this device; cloud metadata is retained.',
|
||||
'cloudSync.convergent.enabled': 'Convergent sync enabled',
|
||||
'cloudSync.convergent.preview.title': 'Migration preview',
|
||||
'cloudSync.convergent.preview.entities': 'Entities',
|
||||
'cloudSync.convergent.preview.providers': 'Providers',
|
||||
'cloudSync.convergent.preview.conflicts': 'Conflicts',
|
||||
'cloudSync.convergent.preview.compatibility': 'A complete v1 snapshot remains in every encrypted payload for older clients.',
|
||||
'cloudSync.convergent.preview.confirm': 'Create CRDT replica',
|
||||
'cloudSync.convergent.preview.status.ready': 'Ready',
|
||||
'cloudSync.convergent.preview.status.empty': 'Empty',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'Unavailable',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Blocked',
|
||||
'cloudSync.convergent.preview.schema': 'schema',
|
||||
'cloudSync.convergent.field.presence': 'presence',
|
||||
'cloudSync.convergent.field.position': 'position',
|
||||
'cloudSync.convergent.conflicts.title': 'Field conflicts ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Empty / deleted',
|
||||
'cloudSync.convergent.conflict.secretSet': 'Secret is set',
|
||||
'cloudSync.convergent.conflict.current': 'current winner',
|
||||
'cloudSync.convergent.conflict.choose': 'Choose',
|
||||
'cloudSync.convergent.conflict.resolved': 'Conflict resolved and synchronized',
|
||||
'cloudSync.convergent.downgrade.desc': 'Replace v2 files on every connected provider with a legacy snapshot.',
|
||||
'cloudSync.convergent.downgrade.button': 'Downgrade',
|
||||
'cloudSync.convergent.downgrade.confirm': 'Downgrade every connected provider to legacy sync? This removes CRDT metadata after write verification.',
|
||||
'cloudSync.convergent.downgrade.done': 'Convergent sync downgraded',
|
||||
'cloudSync.status.title': 'Sync Status',
|
||||
'cloudSync.status.localVersion': 'Local Version',
|
||||
'cloudSync.status.remoteVersion': 'Remote Version',
|
||||
@@ -734,6 +767,10 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.backspaceBehavior': 'Backspace key',
|
||||
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
|
||||
'serial.backspace.default': 'Default (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
|
||||
@@ -50,6 +50,14 @@ export const enVaultMessages: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'First issue: {issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Choose how to read your MobaXterm file.',
|
||||
'vault.import.mobaxterm.encodingQuestion': 'Which text encoding does this file use?',
|
||||
'vault.import.mobaxterm.auto': 'Auto Detect',
|
||||
'vault.import.mobaxterm.autoDesc': 'Recommended. Uses UTF-8 when valid, otherwise Chinese Windows encoding.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
@@ -166,7 +174,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.configure': 'Choose visible columns',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.sort.directoriesFirst': 'Folders first',
|
||||
'sftp.emptyDirectory': 'Empty directory',
|
||||
'sftp.nav.up': 'Go up',
|
||||
'sftp.nav.home': 'Go to home',
|
||||
@@ -186,6 +196,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
@@ -478,6 +489,17 @@ export const enVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': 'Add and continue',
|
||||
'quickConnect.addKey': 'Add key',
|
||||
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
|
||||
'quickConnect.identity.label': 'Credential preset',
|
||||
'quickConnect.identity.placeholder': 'Choose a saved identity',
|
||||
'quickConnect.identity.empty': 'No saved identities found',
|
||||
'quickConnect.identity.hint': 'Choose an identity to fill in its username and authentication automatically.',
|
||||
'quickConnect.identity.selectedHint': 'Ready to connect as {username}.',
|
||||
'quickConnect.identity.method.password': 'Password',
|
||||
'quickConnect.identity.method.key': 'SSH key',
|
||||
'quickConnect.identity.method.certificate': 'Certificate',
|
||||
'quickConnect.identity.unavailable': 'This identity is missing usable credentials on this device.',
|
||||
'quickConnect.identity.useCustom': 'Use custom credentials',
|
||||
'quickConnect.identity.connect': 'Connect with preset',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Connection Error',
|
||||
@@ -495,6 +517,11 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.fileProtocol': 'File Protocol',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto tries SFTP, then falls back to SCP mode when the SFTP subsystem is unavailable (e.g. some NAS). Force SCP for hosts that only support SCP.',
|
||||
'hostDetails.sftp.fileProtocol.auto': 'Auto',
|
||||
'hostDetails.sftp.fileProtocol.sftp': 'SFTP only',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'SCP mode',
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
@@ -615,6 +642,16 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.password.show': 'Show password',
|
||||
'hostDetails.password.hide': 'Hide password',
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.auth.method': 'Login method',
|
||||
'hostDetails.auth.auto': 'Automatic',
|
||||
'hostDetails.auth.auto.desc': 'Try the SSH agent and local keys, then use the saved password.',
|
||||
'hostDetails.auth.passwordOnly': 'Password only',
|
||||
'hostDetails.auth.password.desc': 'Use only the saved password and interactive password prompts.',
|
||||
'hostDetails.auth.key': 'Key',
|
||||
'hostDetails.auth.key.desc': 'Use the selected key, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.certificate': 'Certificate',
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
@@ -624,6 +661,15 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.section.systemSshAgent': 'System SSH Agent',
|
||||
'hostDetails.systemSshAgent': 'Use System SSH Agent to Sign In',
|
||||
'hostDetails.systemSshAgent.desc': 'Use keys already unlocked by macOS, Linux, Windows, 1Password, or another compatible SSH agent',
|
||||
'hostDetails.systemSshAgent.socket': 'Agent Socket',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Leave empty to use SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Only Use Configured Keys',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Do not try unrelated keys currently loaded in the agent',
|
||||
'hostDetails.systemSshAgent.notRunning': 'System SSH agent is unavailable',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Start or unlock your SSH agent, or provide its socket path above.',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
@@ -665,6 +711,11 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': 'Interval (seconds)',
|
||||
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
|
||||
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
|
||||
'hostDetails.section.sshTimeouts': 'SSH Connection Timeouts',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP timeout',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
|
||||
@@ -56,6 +56,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.appServer.title': 'Использовать Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Экспериментально',
|
||||
'ai.codex.appServer.description': 'Постоянный протокол Codex с нативными подтверждениями, песочницей, актуальными моделями и вопросами во время выполнения. SDK остаётся режимом по умолчанию.',
|
||||
'ai.codex.appServer.checking': 'Проверка поддержки App Server…',
|
||||
'ai.codex.appServer.available': 'Эта версия Codex CLI поддерживает App Server.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Динамический каталог моделей Codex недоступен. Используется встроенный список моделей.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Разрешить для сеанса',
|
||||
'ai.codex.appServer.userInput.title': 'Codex требуется ваш ответ',
|
||||
'ai.codex.appServer.userInput.description': 'Ответьте на вопросы, чтобы продолжить текущую задачу.',
|
||||
'ai.codex.appServer.userInput.other': 'Введите другой ответ',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Если ответа не будет вовремя, Codex продолжит автоматически.',
|
||||
'ai.codex.appServer.userInput.skip': 'Пропустить',
|
||||
'ai.codex.appServer.userInput.submit': 'Продолжить',
|
||||
'ai.codex.steer.addInstruction': 'Добавить инструкцию',
|
||||
'ai.codex.steer.sending': 'Инструкция добавляется…',
|
||||
'ai.codex.steer.placeholder': 'Добавьте инструкцию, пока Codex работает…',
|
||||
'ai.codex.steer.notSteerableReview': 'В этот сеанс проверки Codex нельзя добавить инструкцию. Черновик сохранён.',
|
||||
'ai.codex.steer.notSteerableCompact': 'В этот сеанс сжатия Codex нельзя добавить инструкцию. Черновик сохранён.',
|
||||
'ai.codex.steer.busy': 'Другая инструкция уже отправляется в Codex.',
|
||||
'ai.codex.steer.inactive': 'Задача Codex уже завершена. Черновик сохранён.',
|
||||
'ai.codex.steer.unsupported': 'Инструкции во время выполнения доступны только в режиме Codex App Server.',
|
||||
'ai.codex.steer.failed': 'Codex не принял дополнительную инструкцию. Черновик сохранён.',
|
||||
'ai.codex.detecting': 'Обнаружение...',
|
||||
'ai.codex.notFound': 'Не найден',
|
||||
'ai.codex.awaitingLogin': 'Ожидание входа',
|
||||
@@ -180,6 +202,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout': 'Таймаут простоя',
|
||||
'ai.externalMcp.idleTimeout.description': 'В временном режиме отключить External MCP после указанного числа минут без MCP-операций.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Автоматически закрывать сеансы, открытые ИИ, после указанного числа минут без операций терминала или файлов.',
|
||||
'ai.externalMcp.usage.title': 'Как пользоваться',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Включите External MCP и держите Netcatty запущенным.',
|
||||
'ai.externalMcp.usage.localhost': '2. Клиенты подключаются через локальный launcher (только 127.0.0.1). Discovery удаляется при отключении.',
|
||||
|
||||
@@ -40,6 +40,13 @@ export const ruCoreMessages: Messages = {
|
||||
'common.left': 'Слева',
|
||||
'common.right': 'Справа',
|
||||
'common.more': 'Ещё',
|
||||
'toolbar.layout.customize': 'Настроить панель',
|
||||
'toolbar.layout.show': 'Показать',
|
||||
'toolbar.layout.collapse': 'Свернуть',
|
||||
'toolbar.layout.hide': 'Скрыть',
|
||||
'toolbar.layout.moveEarlier': 'Переместить раньше',
|
||||
'toolbar.layout.moveLater': 'Переместить позже',
|
||||
'toolbar.layout.reset': 'Сбросить по умолчанию',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.manual': 'Ручной порядок',
|
||||
@@ -237,6 +244,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.title': 'Хранилище',
|
||||
'settings.vault.showRecentHosts': 'Показывать недавно подключённые хосты',
|
||||
'settings.vault.showRecentHostsDesc': 'Показывать раздел недавно подключённых хостов в верхней части хранилища',
|
||||
'settings.vault.selectBeforeConnect': 'Сначала выбрать, потом подключать',
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
@@ -282,7 +291,110 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
|
||||
|
||||
### Как выбирать элементы
|
||||
|
||||
Используйте селекторы по атрибуту:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-side-panel"] {
|
||||
border: 2px solid #00c851 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
У некоторых строк и панелей также есть хуки состояния вроде \`[data-selected="true"]\` и \`[data-focused="true"]\`.
|
||||
|
||||
Из‑за высокой специфичности утилит Tailwind часто требуется \`!important\`.
|
||||
|
||||
### Верхние вкладки
|
||||
|
||||
- \`top-tabs\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
### Хранилище
|
||||
|
||||
- \`vault-view\`
|
||||
- \`vault-sidebar\`
|
||||
- \`vault-main\`
|
||||
- \`vault-hosts-header\`
|
||||
- \`vault-host-list\`
|
||||
|
||||
### Рабочая область терминала
|
||||
|
||||
- \`terminal-workspace\`
|
||||
- \`terminal-workspace-sidebar\` — список терминалов в режиме Focus
|
||||
- \`terminal-host-tree-sidebar\`
|
||||
- \`terminal-host-tree-sidebar-content\`
|
||||
- \`terminal-host-tree-sidebar-row\`
|
||||
- \`terminal-split-pane\`
|
||||
- \`terminal-split-resizer\`
|
||||
|
||||
### Боковая панель терминала
|
||||
|
||||
- \`terminal-side-panel\` — панель SFTP / скриптов / темы / AI (только пока открыта)
|
||||
- \`terminal-side-panel-tabs\`
|
||||
- \`terminal-side-panel-content\`
|
||||
|
||||
### SFTP
|
||||
|
||||
- \`terminal-sftp-panel\`
|
||||
- \`terminal-sftp-host-header\`
|
||||
- \`terminal-sftp-pane\`
|
||||
- \`terminal-sftp-toolbar\`
|
||||
- \`terminal-sftp-path\`
|
||||
- \`terminal-sftp-filter-bar\`
|
||||
- \`terminal-sftp-list\`
|
||||
- \`terminal-sftp-list-header\`
|
||||
- \`terminal-sftp-list-row\`
|
||||
- \`terminal-sftp-tree\`
|
||||
- \`terminal-sftp-tree-row\`
|
||||
- \`terminal-sftp-transfer-queue\`
|
||||
- \`terminal-sftp-transfer-row\`
|
||||
|
||||
### Панели деталей / инструментов
|
||||
|
||||
- \`snippets-panel\`
|
||||
- \`host-details-panel\`
|
||||
- \`group-details-panel\`
|
||||
- \`serial-host-details-panel\`
|
||||
- \`ai-chat-panel\`
|
||||
|
||||
### Примеры
|
||||
|
||||
Скрыть переключатель списка хостов в верхней панели вкладок:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="top-tabs-host-tree-toggle"] {
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Стилизовать выбранные строки SFTP:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-sftp-list-row"][data-selected="true"] {
|
||||
background-color: #00c851 !important;
|
||||
color: #001b10 !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Подсветить активную панель сплита:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-split-pane"][data-focused="true"] {
|
||||
outline: 2px solid hsl(var(--primary)) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
@@ -340,6 +452,16 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · не рекомендуется (пропорциональный шрифт)',
|
||||
'settings.terminal.font.cjk.option.recommended': 'Рекомендуется для выравнивания в терминале',
|
||||
'settings.terminal.font.cjk.option.installed': 'Установленный шрифт',
|
||||
'settings.terminal.font.cjk.option.unverified': 'Не удалось проверить доступность',
|
||||
'settings.terminal.font.cjk.option.unavailable': 'Не установлен на этом устройстве',
|
||||
'settings.terminal.font.cjk.searchPlaceholder': 'Поиск установленных шрифтов…',
|
||||
'settings.terminal.font.cjk.empty': 'Подходящие шрифты не найдены',
|
||||
'settings.terminal.font.cjk.useCustom': 'Использовать шрифт',
|
||||
'settings.terminal.font.cjk.refresh': 'Обновить список шрифтов',
|
||||
'settings.terminal.font.cjk.alignmentWarning': 'Этот шрифт не проверен как моноширинный. Проверьте выравнивание в предварительном просмотре.',
|
||||
'settings.terminal.font.cjk.unavailableWarning': 'Этот шрифт не установлен на устройстве. Netcatty сохранит настройку и использует безопасный запасной шрифт.',
|
||||
'settings.terminal.font.size': 'Размер шрифта',
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
@@ -381,6 +503,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Копировать при выделении',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Автоматически копировать выделенный текст. В tmux/vim с режимом мыши удерживайте Option на macOS или Shift на Windows/Linux для выделения',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy': 'Нормализовать текст терминала при копировании',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy.desc':
|
||||
'При копировании из терминала убирать отображающие хвостовые пробелы и объединять soft-wrap строки, чтобы вставка соответствовала логическому тексту. Отключите, чтобы копировать сырое выделение экрана (включая padding TUI и визуальные переносы).',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
|
||||
@@ -495,6 +620,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
@@ -532,6 +659,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.autocomplete.popupMenu': 'Всплывающее меню',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Показывать плавающий список из нескольких подсказок.',
|
||||
|
||||
// Settings > Terminal > Password prompt assist (sudo/su)
|
||||
'settings.terminal.section.passwordPromptAssist': 'Подсказки пароля',
|
||||
'settings.terminal.passwordPromptAssist.mode': 'Режим подсказки',
|
||||
'settings.terminal.passwordPromptAssist.mode.desc':
|
||||
'Когда sudo или su запрашивают пароль, предложить сохранённые учётные данные. Никогда не отправляет пароль без подтверждения.',
|
||||
'settings.terminal.passwordPromptAssist.off': 'Выкл.',
|
||||
'settings.terminal.passwordPromptAssist.hint': 'Быстрая вставка (Enter)',
|
||||
'settings.terminal.passwordPromptAssist.picker': 'Список учётных данных',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Схема горячих клавиш',
|
||||
'settings.shortcuts.scheme.label': 'Сочетания клавиш',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': 'Система',
|
||||
'systemManager.overview.kernel': 'Ядро',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Задержка',
|
||||
'systemManager.overview.latency': 'Сетевая задержка SSH',
|
||||
'systemManager.overview.cpuCores': 'Ядра CPU',
|
||||
'systemManager.overview.disks': 'Диски',
|
||||
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
@@ -76,6 +78,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
|
||||
'terminal.toolbar.showActions': 'Показать действия терминала',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -106,9 +110,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.swapFree': 'Свободный swap',
|
||||
'terminal.serverStats.swapTotal': 'Всего',
|
||||
'terminal.serverStats.topProcesses': 'Топ процессов по памяти',
|
||||
'terminal.serverStats.disk': 'Использование диска (корень)',
|
||||
'terminal.serverStats.disk': 'Использование дисков',
|
||||
'terminal.serverStats.diskDetails': 'Смонтированные диски',
|
||||
'terminal.serverStats.network': 'Скорость сети',
|
||||
'terminal.serverStats.latency': 'Сетевая задержка SSH',
|
||||
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
|
||||
'terminal.serverStats.noData': 'Данные недоступны',
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
@@ -353,6 +358,35 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
'cloudSync.strategy.preferLocal': 'Приоритет локальных данных',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Когда изменились обе стороны, загрузить локальную версию и заменить облачные изменения.',
|
||||
'cloudSync.convergent.title': 'Сходящаяся синхронизация устройств',
|
||||
'cloudSync.convergent.experimental': 'Экспериментально',
|
||||
'cloudSync.convergent.desc': 'Зашифрованная CRDT-реплика сохраняет офлайн-правки, параллельные удаления и изменения всех подключённых провайдеров.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 активна. Каждая загрузка проверяется чтением из облака.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 приостановлена на этом устройстве; облачные метаданные сохранены.',
|
||||
'cloudSync.convergent.enabled': 'Сходящаяся синхронизация включена',
|
||||
'cloudSync.convergent.preview.title': 'Предпросмотр миграции',
|
||||
'cloudSync.convergent.preview.entities': 'Объекты',
|
||||
'cloudSync.convergent.preview.providers': 'Провайдеры',
|
||||
'cloudSync.convergent.preview.conflicts': 'Конфликты',
|
||||
'cloudSync.convergent.preview.compatibility': 'Каждая зашифрованная нагрузка сохраняет полный снимок v1 для старых клиентов.',
|
||||
'cloudSync.convergent.preview.confirm': 'Создать CRDT-реплику',
|
||||
'cloudSync.convergent.preview.status.ready': 'Готово',
|
||||
'cloudSync.convergent.preview.status.empty': 'Пусто',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'Недоступно',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Заблокировано',
|
||||
'cloudSync.convergent.preview.schema': 'схема',
|
||||
'cloudSync.convergent.field.presence': 'наличие',
|
||||
'cloudSync.convergent.field.position': 'позиция',
|
||||
'cloudSync.convergent.conflicts.title': 'Конфликты полей ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Пусто / удалено',
|
||||
'cloudSync.convergent.conflict.secretSet': 'Секрет задан',
|
||||
'cloudSync.convergent.conflict.current': 'текущий вариант',
|
||||
'cloudSync.convergent.conflict.choose': 'Выбрать',
|
||||
'cloudSync.convergent.conflict.resolved': 'Конфликт разрешён и синхронизирован',
|
||||
'cloudSync.convergent.downgrade.desc': 'Заменить файлы v2 у всех подключённых провайдеров снимком старого формата.',
|
||||
'cloudSync.convergent.downgrade.button': 'Понизить версию',
|
||||
'cloudSync.convergent.downgrade.confirm': 'Понизить формат у всех подключённых провайдеров? Метаданные CRDT будут удалены после проверки записи.',
|
||||
'cloudSync.convergent.downgrade.done': 'Формат сходящейся синхронизации понижен',
|
||||
'cloudSync.status.title': 'Статус синхронизации',
|
||||
'cloudSync.status.localVersion': 'Локальная версия',
|
||||
'cloudSync.status.remoteVersion': 'Удалённая версия',
|
||||
@@ -738,6 +772,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.localEchoDesc': 'Локально отображать вводимые символы (для устройств без удалённого эха)',
|
||||
'serial.field.lineMode': 'Построчный режим',
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.backspaceBehavior': 'Клавиша Backspace',
|
||||
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
|
||||
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
'serial.connectionError': 'Не удалось подключиться к последовательному порту',
|
||||
'serial.field.baudRatePlaceholder': 'Выберите или введите скорость...',
|
||||
|
||||
@@ -85,6 +85,14 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Выберите способ чтения файла MobaXterm.',
|
||||
'vault.import.mobaxterm.encodingQuestion': 'Какая кодировка используется в этом файле?',
|
||||
'vault.import.mobaxterm.auto': 'Определить автоматически',
|
||||
'vault.import.mobaxterm.autoDesc': 'Рекомендуется. UTF-8, если он корректен, иначе китайская кодировка Windows.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
|
||||
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
|
||||
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
|
||||
'vault.import.sshConfig.importOnly': 'Только импорт',
|
||||
@@ -201,7 +209,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
'sftp.columns.kind': 'Тип',
|
||||
'sftp.columns.configure': 'Выбрать видимые столбцы',
|
||||
'sftp.columns.actions': 'Действия',
|
||||
'sftp.sort.directoriesFirst': 'Папки сверху',
|
||||
'sftp.emptyDirectory': 'Пустой каталог',
|
||||
'sftp.nav.up': 'Наверх',
|
||||
'sftp.nav.home': 'Перейти в домашний каталог',
|
||||
@@ -221,6 +231,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
|
||||
@@ -513,6 +524,17 @@ export const ruVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
|
||||
'quickConnect.addKey': 'Добавить ключ',
|
||||
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
|
||||
'quickConnect.identity.label': 'Набор учётных данных',
|
||||
'quickConnect.identity.placeholder': 'Выберите сохранённый профиль',
|
||||
'quickConnect.identity.empty': 'Сохранённые профили не найдены',
|
||||
'quickConnect.identity.hint': 'Выберите профиль, чтобы автоматически подставить имя пользователя и способ входа.',
|
||||
'quickConnect.identity.selectedHint': 'Готово к подключению как {username}.',
|
||||
'quickConnect.identity.method.password': 'Пароль',
|
||||
'quickConnect.identity.method.key': 'Ключ SSH',
|
||||
'quickConnect.identity.method.certificate': 'Сертификат',
|
||||
'quickConnect.identity.unavailable': 'Для этого профиля нет доступных учётных данных на устройстве.',
|
||||
'quickConnect.identity.useCustom': 'Использовать другие данные',
|
||||
'quickConnect.identity.connect': 'Подключиться с профилем',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Ошибка подключения',
|
||||
@@ -530,6 +552,11 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.section.sftp': 'Настройки SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Протокол файлов',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Авто сначала пробует SFTP, затем SCP-режим, если подсистема SFTP недоступна. Принудительный SCP — для хостов только с SCP.',
|
||||
'hostDetails.sftp.fileProtocol.auto': 'Авто',
|
||||
'hostDetails.sftp.fileProtocol.sftp': 'Только SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'Режим SCP',
|
||||
'hostDetails.sftp.sudo': 'Режим sudo',
|
||||
'hostDetails.sftp.sudo.desc': 'Автоматически получать привилегии Root с помощью сохранённого пароля',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Для режима sudo требуется пароль. Укажите его выше или убедитесь, что сервер разрешает sudo без пароля.',
|
||||
@@ -647,6 +674,16 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.password.show': 'Показать пароль',
|
||||
'hostDetails.password.hide': 'Скрыть пароль',
|
||||
'hostDetails.password.save': 'Сохранить пароль',
|
||||
'hostDetails.auth.method': 'Способ входа',
|
||||
'hostDetails.auth.auto': 'Автоматически',
|
||||
'hostDetails.auth.auto.desc': 'Сначала использовать SSH-агент и локальные ключи, затем сохранённый пароль.',
|
||||
'hostDetails.auth.passwordOnly': 'Только пароль',
|
||||
'hostDetails.auth.password.desc': 'Использовать только сохранённый пароль и интерактивные запросы пароля.',
|
||||
'hostDetails.auth.key': 'Ключ',
|
||||
'hostDetails.auth.key.desc': 'Использовать выбранный ключ и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.certificate': 'Сертификат',
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
'hostDetails.credential.keyCertificate': 'Ключ, сертификат, локальный файл ключа',
|
||||
@@ -656,6 +693,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Обзор...',
|
||||
'hostDetails.credential.missing': 'Учётные данные не найдены',
|
||||
'hostDetails.section.systemSshAgent': 'Системный SSH-агент',
|
||||
'hostDetails.systemSshAgent': 'Использовать системный SSH-агент для входа',
|
||||
'hostDetails.systemSshAgent.desc': 'Использовать ключи, разблокированные macOS, Linux, Windows, 1Password или другим совместимым агентом',
|
||||
'hostDetails.systemSshAgent.socket': 'Сокет агента',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Оставьте пустым, чтобы использовать SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Только настроенные ключи',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Не пробовать другие ключи, загруженные в агент',
|
||||
'hostDetails.systemSshAgent.notRunning': 'Системный SSH-агент недоступен',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Запустите или разблокируйте SSH-агент либо укажите путь к его сокету выше.',
|
||||
'hostDetails.keys.search': 'Поиск ключей...',
|
||||
'hostDetails.keys.empty': 'Нет доступных ключей',
|
||||
'hostDetails.certs.search': 'Поиск сертификатов...',
|
||||
@@ -697,6 +743,11 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': 'Интервал (секунды)',
|
||||
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
|
||||
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
|
||||
'hostDetails.section.sshTimeouts': 'Тайм-ауты SSH-подключения',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'Тайм-аут TCP',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enSystemManagerMessages } from "./en/systemManager";
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { ruSystemManagerMessages } from "./ru/systemManager";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("SSH network latency is explicit in every locale and UI surface", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
|
||||
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
|
||||
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
|
||||
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
|
||||
] as const;
|
||||
|
||||
for (const [terminalMessages, systemManagerMessages, expected] of labels) {
|
||||
assert.equal(terminalMessages["terminal.serverStats.latency"], expected);
|
||||
assert.equal(systemManagerMessages["systemManager.overview.latency"], expected);
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,21 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.chat.activity.title': 'Agent 活动',
|
||||
'ai.chat.activity.plan': '计划',
|
||||
'ai.chat.activity.webSearch': '网页搜索',
|
||||
'ai.chat.activity.fileChanges': '文件变更',
|
||||
'ai.chat.activity.status.running': '进行中',
|
||||
'ai.chat.activity.status.completed': '已完成',
|
||||
'ai.chat.activity.status.failed': '失败',
|
||||
'ai.chat.activity.file.add': '新增',
|
||||
'ai.chat.activity.file.update': '修改',
|
||||
'ai.chat.activity.file.delete': '删除',
|
||||
'ai.chat.activity.usage': 'Token',
|
||||
'ai.chat.activity.usage.input': '输入',
|
||||
'ai.chat.activity.usage.output': '输出',
|
||||
'ai.chat.activity.usage.cached': '缓存',
|
||||
'ai.chat.activity.usage.reasoning': '推理',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
@@ -60,6 +75,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.appServer.title': '使用 Codex App Server',
|
||||
'ai.codex.appServer.experimental': '实验性',
|
||||
'ai.codex.appServer.description': '使用持久化 Codex 协议,提供原生审批、沙箱控制、动态模型和对话中提问。默认仍使用 SDK。',
|
||||
'ai.codex.appServer.checking': '正在检查 App Server 支持…',
|
||||
'ai.codex.appServer.available': '当前 Codex CLI 支持 App Server。',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Codex 动态模型目录暂不可用,当前将使用内置模型列表。',
|
||||
'ai.codex.appServer.approval.allowSession': '本会话允许',
|
||||
'ai.codex.appServer.userInput.title': 'Codex 需要你的输入',
|
||||
'ai.codex.appServer.userInput.description': '回答以下问题以继续当前任务。',
|
||||
'ai.codex.appServer.userInput.other': '输入其他答案',
|
||||
'ai.codex.appServer.userInput.autoResolve': '若未及时回答,Codex 将自动继续。',
|
||||
'ai.codex.appServer.userInput.skip': '跳过',
|
||||
'ai.codex.appServer.userInput.submit': '继续',
|
||||
'ai.codex.steer.addInstruction': '补充指令',
|
||||
'ai.codex.steer.sending': '正在补充指令…',
|
||||
'ai.codex.steer.placeholder': '在 Codex 工作时补充指令…',
|
||||
'ai.codex.steer.notSteerableReview': '当前 Codex 审查任务无法接收补充指令,草稿已保留。',
|
||||
'ai.codex.steer.notSteerableCompact': '当前 Codex 压缩任务无法接收补充指令,草稿已保留。',
|
||||
'ai.codex.steer.busy': '已有一条补充指令正在发送给 Codex。',
|
||||
'ai.codex.steer.inactive': '当前 Codex 任务已经结束,草稿已保留。',
|
||||
'ai.codex.steer.unsupported': '运行中补充指令仅支持 Codex App Server。',
|
||||
'ai.codex.steer.failed': 'Codex 未能接收补充指令,草稿已保留。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
@@ -197,6 +234,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout': '空闲超时',
|
||||
'ai.externalMcp.idleTimeout.description': '临时模式下,若超过该分钟数没有 MCP 操作,将自动关闭对外 MCP。',
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'AI 打开的会话若超过该分钟数没有终端或文件操作,将自动关闭。',
|
||||
'ai.externalMcp.usage.title': '使用说明',
|
||||
'ai.externalMcp.usage.keepRunning': '1. 打开对外 MCP 开关,并保持 Netcatty 运行。',
|
||||
'ai.externalMcp.usage.localhost': '2. 客户端通过本地 launcher 连接(仅 127.0.0.1)。关闭开关后会删除 discovery。',
|
||||
|
||||
@@ -28,6 +28,13 @@ export const zhCNCoreMessages: Messages = {
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'toolbar.layout.customize': '自定义工具栏',
|
||||
'toolbar.layout.show': '显示',
|
||||
'toolbar.layout.collapse': '折叠',
|
||||
'toolbar.layout.hide': '隐藏',
|
||||
'toolbar.layout.moveEarlier': '前移',
|
||||
'toolbar.layout.moveLater': '后移',
|
||||
'toolbar.layout.reset': '恢复默认',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.manual': '手动顺序',
|
||||
'sort.az': 'A-z',
|
||||
@@ -246,6 +253,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
'settings.vault.selectBeforeConnect': '先选中再连接',
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
@@ -291,7 +300,110 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
|
||||
|
||||
### 如何选择元素
|
||||
|
||||
使用属性选择器:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-side-panel"] {
|
||||
border: 2px solid #00c851 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
部分行 / 窗格还支持 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等状态钩子。
|
||||
|
||||
由于 Tailwind 工具类优先级较高,覆盖样式时通常需要使用 \`!important\`。
|
||||
|
||||
### 顶部标签栏
|
||||
|
||||
- \`top-tabs\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
### 保险库
|
||||
|
||||
- \`vault-view\`
|
||||
- \`vault-sidebar\`
|
||||
- \`vault-main\`
|
||||
- \`vault-hosts-header\`
|
||||
- \`vault-host-list\`
|
||||
|
||||
### 终端工作区
|
||||
|
||||
- \`terminal-workspace\`
|
||||
- \`terminal-workspace-sidebar\` — Focus 模式终端列表
|
||||
- \`terminal-host-tree-sidebar\`
|
||||
- \`terminal-host-tree-sidebar-content\`
|
||||
- \`terminal-host-tree-sidebar-row\`
|
||||
- \`terminal-split-pane\`
|
||||
- \`terminal-split-resizer\`
|
||||
|
||||
### 终端侧栏
|
||||
|
||||
- \`terminal-side-panel\` — SFTP / 脚本 / 主题 / AI 侧栏(仅在打开时存在)
|
||||
- \`terminal-side-panel-tabs\`
|
||||
- \`terminal-side-panel-content\`
|
||||
|
||||
### SFTP
|
||||
|
||||
- \`terminal-sftp-panel\`
|
||||
- \`terminal-sftp-host-header\`
|
||||
- \`terminal-sftp-pane\`
|
||||
- \`terminal-sftp-toolbar\`
|
||||
- \`terminal-sftp-path\`
|
||||
- \`terminal-sftp-filter-bar\`
|
||||
- \`terminal-sftp-list\`
|
||||
- \`terminal-sftp-list-header\`
|
||||
- \`terminal-sftp-list-row\`
|
||||
- \`terminal-sftp-tree\`
|
||||
- \`terminal-sftp-tree-row\`
|
||||
- \`terminal-sftp-transfer-queue\`
|
||||
- \`terminal-sftp-transfer-row\`
|
||||
|
||||
### 详情 / 工具面板
|
||||
|
||||
- \`snippets-panel\`
|
||||
- \`host-details-panel\`
|
||||
- \`group-details-panel\`
|
||||
- \`serial-host-details-panel\`
|
||||
- \`ai-chat-panel\`
|
||||
|
||||
### 示例
|
||||
|
||||
隐藏顶部标签栏里的主机列表开关:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="top-tabs-host-tree-toggle"] {
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
修改选中的 SFTP 文件行:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-sftp-list-row"][data-selected="true"] {
|
||||
background-color: #00c851 !important;
|
||||
color: #001b10 !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
高亮当前聚焦的分屏:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-split-pane"][data-focused="true"] {
|
||||
outline: 2px solid hsl(var(--primary)) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
@@ -530,6 +642,14 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
|
||||
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首个问题:{issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': '选择如何读取 MobaXterm 文件。',
|
||||
'vault.import.mobaxterm.encodingQuestion': '这个文件使用哪种文字编码?',
|
||||
'vault.import.mobaxterm.auto': '自动检测',
|
||||
'vault.import.mobaxterm.autoDesc': '推荐。能按 UTF-8 读取时使用 UTF-8,否则使用中文 Windows 编码。',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
@@ -619,10 +739,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.configure': '选择显示的列',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '类型',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.sort.directoriesFirst': '目录置顶',
|
||||
'sftp.emptyDirectory': '空目录',
|
||||
'sftp.nav.up': '返回上层',
|
||||
'sftp.nav.home': '返回主目录',
|
||||
@@ -642,6 +764,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
'sftp.copyCurrentPath.error': '无法复制当前路径',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': '系统',
|
||||
'systemManager.overview.kernel': '内核',
|
||||
'systemManager.overview.swap': '交换区',
|
||||
'systemManager.overview.latency': '延迟',
|
||||
'systemManager.overview.latency': 'SSH 网络延迟',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁盘',
|
||||
'systemManager.overview.interfaces': '网络接口',
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.uploadClipboardImage': '上传剪贴板图片',
|
||||
'terminal.clipboardImageUpload.noImage': '剪贴板中没有图片',
|
||||
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
|
||||
'terminal.toolbar.configureOsc7': '配置目录追踪',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.dragPane': '拖动终端窗格',
|
||||
'terminal.toolbar.showActions': '显示终端操作',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.osc7Setup.title': '配置目录追踪',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 会为当前远端用户添加 OSC 7 提示符配置。这样在 sudo 或 su 之后,SFTP 也能继续跟随终端目录。',
|
||||
@@ -208,6 +212,16 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
|
||||
'settings.terminal.font.cjk.option.recommended': '推荐用于终端对齐',
|
||||
'settings.terminal.font.cjk.option.installed': '本机已安装字体',
|
||||
'settings.terminal.font.cjk.option.unverified': '暂时无法检查是否安装',
|
||||
'settings.terminal.font.cjk.option.unavailable': '本机未安装',
|
||||
'settings.terminal.font.cjk.searchPlaceholder': '搜索本机字体…',
|
||||
'settings.terminal.font.cjk.empty': '没有匹配的本机字体',
|
||||
'settings.terminal.font.cjk.useCustom': '使用字体',
|
||||
'settings.terminal.font.cjk.refresh': '刷新本机字体',
|
||||
'settings.terminal.font.cjk.alignmentWarning': '这个字体尚未确认是等宽字体,请通过预览检查文字是否对齐。',
|
||||
'settings.terminal.font.cjk.unavailableWarning': '本机没有安装这个字体。Netcatty 会保留设置,并使用安全字体继续显示。',
|
||||
'settings.terminal.font.size': '字体大小',
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
@@ -246,6 +260,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy': '复制时规范化终端文本',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy.desc':
|
||||
'复制终端内容时去掉仅用于显示的行尾填充空格,并拼接软换行,使粘贴结果更接近逻辑文本。关闭后按屏幕选区原样复制(含 TUI 填充空格与视觉折行)。',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.middleClick': '中键行为',
|
||||
@@ -357,6 +374,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
@@ -405,6 +424,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
|
||||
'settings.terminal.section.passwordPromptAssist': '密码提示辅助',
|
||||
'settings.terminal.passwordPromptAssist.mode': '辅助模式',
|
||||
'settings.terminal.passwordPromptAssist.mode.desc':
|
||||
'当 sudo 或 su 要求输入密码时,提供已保存的凭据。不会在未经确认时自动发送。',
|
||||
'settings.terminal.passwordPromptAssist.off': '关闭',
|
||||
'settings.terminal.passwordPromptAssist.hint': '快速填充(Enter)',
|
||||
'settings.terminal.passwordPromptAssist.picker': '凭据选择列表',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
@@ -772,6 +798,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.backspaceBehavior': '回退键',
|
||||
'serial.field.backspaceBehaviorDesc': '如果网络设备不响应默认回退键,请改用 Ctrl+H。',
|
||||
'serial.backspace.default': '默认(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
|
||||
@@ -50,6 +50,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': '加入并继续',
|
||||
'quickConnect.addKey': '添加 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
|
||||
'quickConnect.identity.label': '凭据预设',
|
||||
'quickConnect.identity.placeholder': '选择已保存的身份',
|
||||
'quickConnect.identity.empty': '没有可用的身份',
|
||||
'quickConnect.identity.hint': '选择身份后会自动带入用户名和认证信息。',
|
||||
'quickConnect.identity.selectedHint': '将以 {username} 连接。',
|
||||
'quickConnect.identity.method.password': '密码',
|
||||
'quickConnect.identity.method.key': 'SSH 密钥',
|
||||
'quickConnect.identity.method.certificate': '证书',
|
||||
'quickConnect.identity.unavailable': '此身份在当前设备上缺少可用凭据。',
|
||||
'quickConnect.identity.useCustom': '改用自定义凭据',
|
||||
'quickConnect.identity.connect': '使用预设连接',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '选择协议',
|
||||
@@ -63,6 +74,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.fileProtocol': '文件协议',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自动优先 SFTP;当服务器无 SFTP 子系统时回退到 SCP 模式(如部分 NAS)。仅支持 SCP 的主机可强制 SCP。',
|
||||
'hostDetails.sftp.fileProtocol.auto': '自动',
|
||||
'hostDetails.sftp.fileProtocol.sftp': '仅 SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'SCP 模式',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
@@ -183,6 +199,16 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.password.show': '显示密码',
|
||||
'hostDetails.password.hide': '隐藏密码',
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.auth.method': '登录方式',
|
||||
'hostDetails.auth.auto': '自动选择',
|
||||
'hostDetails.auth.auto.desc': '先尝试系统密钥和本地密钥,最后使用已保存的密码。',
|
||||
'hostDetails.auth.passwordOnly': '仅密码',
|
||||
'hostDetails.auth.password.desc': '只使用已保存的密码和交互式密码提示。',
|
||||
'hostDetails.auth.key': '密钥',
|
||||
'hostDetails.auth.key.desc': '使用选中的密钥;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.certificate': '证书',
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
@@ -192,6 +218,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.section.systemSshAgent': '系统 SSH 密钥',
|
||||
'hostDetails.systemSshAgent': '使用系统 SSH 密钥登录',
|
||||
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他兼容服务解锁的密钥',
|
||||
'hostDetails.systemSshAgent.socket': '代理地址',
|
||||
'hostDetails.systemSshAgent.socket.desc': '留空时使用 SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': '仅使用已配置的密钥',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': '不尝试代理中其他无关密钥',
|
||||
'hostDetails.systemSshAgent.notRunning': '系统 SSH 密钥服务不可用',
|
||||
'hostDetails.systemSshAgent.notRunningHint': '请启动或解锁密钥服务,或者在上方填写它的地址。',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
@@ -233,6 +268,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': '间隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大无响应保活次数',
|
||||
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
|
||||
'hostDetails.section.sshTimeouts': 'SSH 连接超时',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP 连接超时(秒)',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
@@ -379,10 +419,10 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.disk': '磁盘使用',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.latency': '延迟',
|
||||
'terminal.serverStats.latency': 'SSH 网络延迟',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
@@ -614,6 +654,35 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
'cloudSync.strategy.preferLocal': '本地优先',
|
||||
'cloudSync.strategy.preferLocalDesc': '两边都有变化时,上传本地版本,并替换云端变化。',
|
||||
'cloudSync.convergent.title': '可收敛多设备同步',
|
||||
'cloudSync.convergent.experimental': '实验性',
|
||||
'cloudSync.convergent.desc': '使用加密 CRDT 副本保留离线编辑、并发删除,以及所有已连接云服务中的变化。',
|
||||
'cloudSync.convergent.active': 'CRDT v2 已启用;每次上传后都会验证云端写入。',
|
||||
'cloudSync.convergent.paused': '此设备已暂停 CRDT v2;云端元数据仍会保留。',
|
||||
'cloudSync.convergent.enabled': '已启用可收敛同步',
|
||||
'cloudSync.convergent.preview.title': '迁移预览',
|
||||
'cloudSync.convergent.preview.entities': '实体',
|
||||
'cloudSync.convergent.preview.providers': '云服务',
|
||||
'cloudSync.convergent.preview.conflicts': '冲突',
|
||||
'cloudSync.convergent.preview.compatibility': '每个加密载荷仍保留完整 v1 快照,供旧客户端读取。',
|
||||
'cloudSync.convergent.preview.confirm': '创建 CRDT 副本',
|
||||
'cloudSync.convergent.preview.status.ready': '就绪',
|
||||
'cloudSync.convergent.preview.status.empty': '为空',
|
||||
'cloudSync.convergent.preview.status.unavailable': '不可用',
|
||||
'cloudSync.convergent.preview.status.blocked': '已阻断',
|
||||
'cloudSync.convergent.preview.schema': '协议版本',
|
||||
'cloudSync.convergent.field.presence': '存在状态',
|
||||
'cloudSync.convergent.field.position': '排序位置',
|
||||
'cloudSync.convergent.conflicts.title': '字段冲突({count})',
|
||||
'cloudSync.convergent.conflict.empty': '为空 / 已删除',
|
||||
'cloudSync.convergent.conflict.secretSet': '秘密值已设置',
|
||||
'cloudSync.convergent.conflict.current': '当前候选',
|
||||
'cloudSync.convergent.conflict.choose': '选择',
|
||||
'cloudSync.convergent.conflict.resolved': '冲突已解决并同步',
|
||||
'cloudSync.convergent.downgrade.desc': '将所有已连接云服务中的 v2 文件替换为旧版快照。',
|
||||
'cloudSync.convergent.downgrade.button': '降级',
|
||||
'cloudSync.convergent.downgrade.confirm': '确定将所有已连接云服务降级为旧版同步吗?写后验证通过后会移除 CRDT 元数据。',
|
||||
'cloudSync.convergent.downgrade.done': '可收敛同步已降级',
|
||||
'cloudSync.status.title': '同步状态',
|
||||
'cloudSync.status.localVersion': '本地版本',
|
||||
'cloudSync.status.remoteVersion': '远端版本',
|
||||
|
||||
@@ -8,6 +8,21 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
|
||||
'ai.chat.activity.title': 'Agent 活動',
|
||||
'ai.chat.activity.plan': '計畫',
|
||||
'ai.chat.activity.webSearch': '網頁搜尋',
|
||||
'ai.chat.activity.fileChanges': '檔案變更',
|
||||
'ai.chat.activity.status.running': '進行中',
|
||||
'ai.chat.activity.status.completed': '已完成',
|
||||
'ai.chat.activity.status.failed': '失敗',
|
||||
'ai.chat.activity.file.add': '新增',
|
||||
'ai.chat.activity.file.update': '修改',
|
||||
'ai.chat.activity.file.delete': '刪除',
|
||||
'ai.chat.activity.usage': 'Token',
|
||||
'ai.chat.activity.usage.input': '輸入',
|
||||
'ai.chat.activity.usage.output': '輸出',
|
||||
'ai.chat.activity.usage.cached': '快取',
|
||||
'ai.chat.activity.usage.reasoning': '推理',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '設定 AI 供應商、Agent 和安全設定',
|
||||
'ai.providers': '供應商',
|
||||
@@ -60,6 +75,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
|
||||
'ai.codex.appServer.title': '使用 Codex App Server',
|
||||
'ai.codex.appServer.experimental': '實驗性',
|
||||
'ai.codex.appServer.description': '使用持久化 Codex 協定,提供原生審批、沙箱控制、動態模型和對話中提問。預設仍使用 SDK。',
|
||||
'ai.codex.appServer.checking': '正在檢查 App Server 支援…',
|
||||
'ai.codex.appServer.available': '目前 Codex CLI 支援 App Server。',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Codex 動態模型目錄暫時無法使用,目前將使用內建模型清單。',
|
||||
'ai.codex.appServer.approval.allowSession': '本工作階段允許',
|
||||
'ai.codex.appServer.userInput.title': 'Codex 需要你的輸入',
|
||||
'ai.codex.appServer.userInput.description': '回答以下問題以繼續目前任務。',
|
||||
'ai.codex.appServer.userInput.other': '輸入其他答案',
|
||||
'ai.codex.appServer.userInput.autoResolve': '若未及時回答,Codex 將自動繼續。',
|
||||
'ai.codex.appServer.userInput.skip': '略過',
|
||||
'ai.codex.appServer.userInput.submit': '繼續',
|
||||
'ai.codex.steer.addInstruction': '補充指令',
|
||||
'ai.codex.steer.sending': '正在補充指令…',
|
||||
'ai.codex.steer.placeholder': '在 Codex 工作時補充指令…',
|
||||
'ai.codex.steer.notSteerableReview': '目前 Codex 審查任務無法接收補充指令,草稿已保留。',
|
||||
'ai.codex.steer.notSteerableCompact': '目前 Codex 壓縮任務無法接收補充指令,草稿已保留。',
|
||||
'ai.codex.steer.busy': '已有一則補充指令正在傳送給 Codex。',
|
||||
'ai.codex.steer.inactive': '目前 Codex 任務已經結束,草稿已保留。',
|
||||
'ai.codex.steer.unsupported': '執行中補充指令僅支援 Codex App Server。',
|
||||
'ai.codex.steer.failed': 'Codex 無法接收補充指令,草稿已保留。',
|
||||
'ai.codex.detecting': '偵測中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登入',
|
||||
@@ -196,6 +233,10 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout': '閒置逾時',
|
||||
'ai.externalMcp.idleTimeout.description': '暫時模式下,若超過該分鐘數沒有 MCP 操作,將自動關閉對外 MCP。',
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'AI 開啟的工作階段若超過該分鐘數沒有終端或檔案操作,將自動關閉。',
|
||||
'ai.externalMcp.usage.title': '使用說明',
|
||||
'ai.externalMcp.usage.keepRunning': '1. 開啟對外 MCP 開關,並保持 Netcatty 執行。',
|
||||
'ai.externalMcp.usage.localhost': '2. 用戶端透過本機 launcher 連線(僅 127.0.0.1)。關閉開關後會刪除 discovery。',
|
||||
|
||||
@@ -28,6 +28,13 @@ export const zhTWCoreMessages: Messages = {
|
||||
'common.left': '左側',
|
||||
'common.right': '右側',
|
||||
'common.more': '更多',
|
||||
'toolbar.layout.customize': '自訂工具列',
|
||||
'toolbar.layout.show': '顯示',
|
||||
'toolbar.layout.collapse': '摺疊',
|
||||
'toolbar.layout.hide': '隱藏',
|
||||
'toolbar.layout.moveEarlier': '前移',
|
||||
'toolbar.layout.moveLater': '後移',
|
||||
'toolbar.layout.reset': '恢復預設',
|
||||
'common.selectAHost': '選擇主機',
|
||||
'sort.manual': '手動順序',
|
||||
'sort.az': 'A-z',
|
||||
@@ -246,6 +253,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.title': '主機庫',
|
||||
'settings.vault.showRecentHosts': '顯示最近連線的主機',
|
||||
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
|
||||
'settings.vault.selectBeforeConnect': '先選取再連線',
|
||||
'settings.vault.selectBeforeConnectDesc': '單擊選取主機(網格模式顯示強調色邊框),再次單擊連線;分組同理。預設仍為單擊直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
|
||||
@@ -291,7 +300,110 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。主要 UI 區塊都公開了 [data-section="..."] 屬性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式終端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/指令碼/主題/AI 側欄,開啟時生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
|
||||
|
||||
### 如何選擇元素
|
||||
|
||||
使用屬性選擇器:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-side-panel"] {
|
||||
border: 2px solid #00c851 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
部分列 / 窗格還支援 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等狀態鉤子。
|
||||
|
||||
由於 Tailwind 工具類優先順序較高,覆蓋樣式時通常需要使用 \`!important\`。
|
||||
|
||||
### 頂部標籤列
|
||||
|
||||
- \`top-tabs\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
### 保險庫
|
||||
|
||||
- \`vault-view\`
|
||||
- \`vault-sidebar\`
|
||||
- \`vault-main\`
|
||||
- \`vault-hosts-header\`
|
||||
- \`vault-host-list\`
|
||||
|
||||
### 終端工作區
|
||||
|
||||
- \`terminal-workspace\`
|
||||
- \`terminal-workspace-sidebar\` — Focus 模式終端列表
|
||||
- \`terminal-host-tree-sidebar\`
|
||||
- \`terminal-host-tree-sidebar-content\`
|
||||
- \`terminal-host-tree-sidebar-row\`
|
||||
- \`terminal-split-pane\`
|
||||
- \`terminal-split-resizer\`
|
||||
|
||||
### 終端側欄
|
||||
|
||||
- \`terminal-side-panel\` — SFTP / 指令碼 / 主題 / AI 側欄(僅在開啟時存在)
|
||||
- \`terminal-side-panel-tabs\`
|
||||
- \`terminal-side-panel-content\`
|
||||
|
||||
### SFTP
|
||||
|
||||
- \`terminal-sftp-panel\`
|
||||
- \`terminal-sftp-host-header\`
|
||||
- \`terminal-sftp-pane\`
|
||||
- \`terminal-sftp-toolbar\`
|
||||
- \`terminal-sftp-path\`
|
||||
- \`terminal-sftp-filter-bar\`
|
||||
- \`terminal-sftp-list\`
|
||||
- \`terminal-sftp-list-header\`
|
||||
- \`terminal-sftp-list-row\`
|
||||
- \`terminal-sftp-tree\`
|
||||
- \`terminal-sftp-tree-row\`
|
||||
- \`terminal-sftp-transfer-queue\`
|
||||
- \`terminal-sftp-transfer-row\`
|
||||
|
||||
### 詳情 / 工具面板
|
||||
|
||||
- \`snippets-panel\`
|
||||
- \`host-details-panel\`
|
||||
- \`group-details-panel\`
|
||||
- \`serial-host-details-panel\`
|
||||
- \`ai-chat-panel\`
|
||||
|
||||
### 範例
|
||||
|
||||
隱藏頂部標籤列裡的主機列表開關:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="top-tabs-host-tree-toggle"] {
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
修改選取的 SFTP 檔案列:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-sftp-list-row"][data-selected="true"] {
|
||||
background-color: #00c851 !important;
|
||||
color: #001b10 !important;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
標示目前聚焦的分割窗格:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="terminal-split-pane"][data-focused="true"] {
|
||||
outline: 2px solid hsl(var(--primary)) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部標籤欄裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部標籤 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
@@ -530,6 +642,14 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.toast.noNewHosts': '從 {format} 沒有匯入到新的主機。',
|
||||
'vault.import.toast.summary': '已匯入 {count} 個主機(跳過 {skipped},重複 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首個問題:{issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': '選擇如何讀取 MobaXterm 檔案。',
|
||||
'vault.import.mobaxterm.encodingQuestion': '這個檔案使用哪種文字編碼?',
|
||||
'vault.import.mobaxterm.auto': '自動偵測',
|
||||
'vault.import.mobaxterm.autoDesc': '建議。能以 UTF-8 讀取時使用 UTF-8,否則使用中文 Windows 編碼。',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
|
||||
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
|
||||
'vault.import.sshConfig.importOnly': '僅匯入',
|
||||
@@ -619,10 +739,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
|
||||
'sftp.bookmark.empty': '暫無收藏路徑',
|
||||
'sftp.columns.name': '名稱',
|
||||
'sftp.columns.configure': '選擇顯示的欄位',
|
||||
'sftp.columns.modified': '修改時間',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '型別',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.sort.directoriesFirst': '資料夾置頂',
|
||||
'sftp.emptyDirectory': '空目錄',
|
||||
'sftp.nav.up': '返回上層',
|
||||
'sftp.nav.home': '返回主目錄',
|
||||
@@ -642,6 +764,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||
'sftp.copyCurrentPath.error': '無法複製目前路徑',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': '系統',
|
||||
'systemManager.overview.kernel': '核心',
|
||||
'systemManager.overview.swap': '交換區',
|
||||
'systemManager.overview.latency': '延遲',
|
||||
'systemManager.overview.latency': 'SSH 網路延遲',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁碟',
|
||||
'systemManager.overview.interfaces': '網路介面',
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上 sudo 密碼',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
'terminal.menu.rename': '重新命名',
|
||||
'terminal.menu.uploadClipboardImage': '上傳剪貼簿圖片',
|
||||
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
|
||||
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
|
||||
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.toolbar.dragPane': '拖動終端窗格',
|
||||
'terminal.toolbar.showActions': '顯示終端操作',
|
||||
'terminal.menu.detach': '從工作區移出',
|
||||
'terminal.osc7Setup.title': '設定目錄追蹤',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 會為目前遠端使用者新增 OSC 7 提示字元設定。這樣在 sudo 或 su 之後,SFTP 也能繼續跟隨終端目錄。',
|
||||
@@ -208,6 +212,16 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鶩文楷等寬)',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋體)',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · 不推薦(非等寬字型)',
|
||||
'settings.terminal.font.cjk.option.recommended': '推薦用於終端對齊',
|
||||
'settings.terminal.font.cjk.option.installed': '本機已安裝字型',
|
||||
'settings.terminal.font.cjk.option.unverified': '暫時無法檢查是否安裝',
|
||||
'settings.terminal.font.cjk.option.unavailable': '本機未安裝',
|
||||
'settings.terminal.font.cjk.searchPlaceholder': '搜尋本機字型…',
|
||||
'settings.terminal.font.cjk.empty': '沒有符合的本機字型',
|
||||
'settings.terminal.font.cjk.useCustom': '使用字型',
|
||||
'settings.terminal.font.cjk.refresh': '重新整理本機字型',
|
||||
'settings.terminal.font.cjk.alignmentWarning': '這個字型尚未確認是等寬字型,請透過預覽檢查文字是否對齊。',
|
||||
'settings.terminal.font.cjk.unavailableWarning': '本機沒有安裝這個字型。Netcatty 會保留設定,並使用安全字型繼續顯示。',
|
||||
'settings.terminal.font.size': '字型大小',
|
||||
'settings.terminal.font.size.desc': '終端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
@@ -246,6 +260,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
|
||||
'settings.terminal.behavior.copyOnSelect': '選擇即複製',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自動複製選取的文字。在 tmux/vim 滑鼠模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖選即可選取文字',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy': '複製時正規化終端文字',
|
||||
'settings.terminal.behavior.normalizeTextOnCopy.desc':
|
||||
'複製終端內容時去除僅供顯示的行尾填充空白,並拼接軟換行,使貼上結果更接近邏輯文字。關閉後依螢幕選取原樣複製(含 TUI 填充空白與視覺折行)。',
|
||||
'settings.terminal.behavior.middleClickPaste': '中鍵貼上',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中鍵點選時貼上剪貼簿內容',
|
||||
'settings.terminal.behavior.middleClick': '中鍵行為',
|
||||
@@ -357,6 +374,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示地址。留空則使用系統預設值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自動(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
|
||||
'settings.terminal.serverStats.show': '顯示伺服器狀態',
|
||||
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
|
||||
@@ -405,6 +424,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在游標後顯示灰色的建議文字(類似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '彈出選單',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '顯示包含多個建議的浮動列表。',
|
||||
'settings.terminal.section.passwordPromptAssist': '密碼提示輔助',
|
||||
'settings.terminal.passwordPromptAssist.mode': '輔助模式',
|
||||
'settings.terminal.passwordPromptAssist.mode.desc':
|
||||
'當 sudo 或 su 要求輸入密碼時,提供已儲存的憑證。不會在未經確認時自動傳送。',
|
||||
'settings.terminal.passwordPromptAssist.off': '關閉',
|
||||
'settings.terminal.passwordPromptAssist.hint': '快速填入(Enter)',
|
||||
'settings.terminal.passwordPromptAssist.picker': '憑證選擇列表',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷鍵方案',
|
||||
@@ -772,6 +798,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.localEchoDesc': '本機回顯輸入字元(用於沒有遠端回顯的裝置)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按回車後傳送(而不是逐字元傳送)',
|
||||
'serial.field.backspaceBehavior': '退格鍵',
|
||||
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
|
||||
'serial.backspace.default': '預設(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
'serial.connectionError': '連線序列埠失敗',
|
||||
'serial.field.baudRatePlaceholder': '選擇或輸入波特率...',
|
||||
|
||||
@@ -50,6 +50,17 @@ export const zhTWVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': '加入並繼續',
|
||||
'quickConnect.addKey': '新增 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 引數已被忽略: {options}',
|
||||
'quickConnect.identity.label': '憑據預設',
|
||||
'quickConnect.identity.placeholder': '選擇已儲存的身分',
|
||||
'quickConnect.identity.empty': '沒有可用的身分',
|
||||
'quickConnect.identity.hint': '選擇身分後會自動帶入使用者名稱和驗證資訊。',
|
||||
'quickConnect.identity.selectedHint': '將以 {username} 連線。',
|
||||
'quickConnect.identity.method.password': '密碼',
|
||||
'quickConnect.identity.method.key': 'SSH 金鑰',
|
||||
'quickConnect.identity.method.certificate': '憑證',
|
||||
'quickConnect.identity.unavailable': '此身分在目前裝置上缺少可用憑據。',
|
||||
'quickConnect.identity.useCustom': '改用自訂憑據',
|
||||
'quickConnect.identity.connect': '使用預設連線',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '選擇協定',
|
||||
@@ -62,6 +73,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 設定',
|
||||
'hostDetails.sftp.fileProtocol': '檔案協定',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時回退到 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
|
||||
'hostDetails.sftp.fileProtocol.auto': '自動',
|
||||
'hostDetails.sftp.fileProtocol.sftp': '僅 SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'SCP 模式',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提權模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用儲存的密碼自動取得 Root 權限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密碼。請在上方設定密碼,或確保伺服器允許免密 sudo。',
|
||||
@@ -183,6 +199,16 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.password.show': '顯示密碼',
|
||||
'hostDetails.password.hide': '隱藏密碼',
|
||||
'hostDetails.password.save': '儲存密碼',
|
||||
'hostDetails.auth.method': '登入方式',
|
||||
'hostDetails.auth.auto': '自動選擇',
|
||||
'hostDetails.auth.auto.desc': '先嘗試系統金鑰和本機金鑰,最後使用已儲存的密碼。',
|
||||
'hostDetails.auth.passwordOnly': '僅密碼',
|
||||
'hostDetails.auth.password.desc': '只使用已儲存的密碼和互動式密碼提示。',
|
||||
'hostDetails.auth.key': '金鑰',
|
||||
'hostDetails.auth.key.desc': '使用選取的金鑰;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.certificate': '憑證',
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
|
||||
@@ -192,6 +218,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '瀏覽…',
|
||||
'hostDetails.credential.missing': '憑證不存在',
|
||||
'hostDetails.section.systemSshAgent': '系統 SSH 金鑰',
|
||||
'hostDetails.systemSshAgent': '使用系統 SSH 金鑰登入',
|
||||
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他相容服務解鎖的金鑰',
|
||||
'hostDetails.systemSshAgent.socket': '代理位址',
|
||||
'hostDetails.systemSshAgent.socket.desc': '留空時使用 SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': '僅使用已設定的金鑰',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': '不嘗試代理中其他無關金鑰',
|
||||
'hostDetails.systemSshAgent.notRunning': '系統 SSH 金鑰服務無法使用',
|
||||
'hostDetails.systemSshAgent.notRunningHint': '請啟動或解鎖金鑰服務,或在上方填寫其位址。',
|
||||
'hostDetails.keys.search': '搜尋金鑰…',
|
||||
'hostDetails.keys.empty': '暫無金鑰',
|
||||
'hostDetails.certs.search': '搜尋憑證…',
|
||||
@@ -233,6 +268,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': '間隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大無響應保活次數',
|
||||
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
|
||||
'hostDetails.section.sshTimeouts': 'SSH 連線逾時',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP 連線逾時(秒)',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
@@ -379,10 +419,10 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.swapFree': '空閒交換',
|
||||
'terminal.serverStats.swapTotal': '總計',
|
||||
'terminal.serverStats.topProcesses': '記憶體佔用前十程序',
|
||||
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
|
||||
'terminal.serverStats.disk': '磁碟使用',
|
||||
'terminal.serverStats.diskDetails': '已掛載磁碟',
|
||||
'terminal.serverStats.network': '網路速度',
|
||||
'terminal.serverStats.latency': '延遲',
|
||||
'terminal.serverStats.latency': 'SSH 網路延遲',
|
||||
'terminal.serverStats.networkDetails': '網路介面',
|
||||
'terminal.serverStats.noData': '暫無資料',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路徑',
|
||||
@@ -614,6 +654,35 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
'cloudSync.strategy.preferLocal': '本機優先',
|
||||
'cloudSync.strategy.preferLocalDesc': '兩邊都有變化時,上傳本機版本,並替換雲端變化。',
|
||||
'cloudSync.convergent.title': '可收斂多裝置同步',
|
||||
'cloudSync.convergent.experimental': '實驗性',
|
||||
'cloudSync.convergent.desc': '使用加密 CRDT 副本保留離線編輯、並行刪除,以及所有已連線雲端服務中的變化。',
|
||||
'cloudSync.convergent.active': 'CRDT v2 已啟用;每次上傳後都會驗證雲端寫入。',
|
||||
'cloudSync.convergent.paused': '此裝置已暫停 CRDT v2;雲端中繼資料仍會保留。',
|
||||
'cloudSync.convergent.enabled': '已啟用可收斂同步',
|
||||
'cloudSync.convergent.preview.title': '遷移預覽',
|
||||
'cloudSync.convergent.preview.entities': '實體',
|
||||
'cloudSync.convergent.preview.providers': '雲端服務',
|
||||
'cloudSync.convergent.preview.conflicts': '衝突',
|
||||
'cloudSync.convergent.preview.compatibility': '每個加密載荷仍保留完整 v1 快照,供舊版用戶端讀取。',
|
||||
'cloudSync.convergent.preview.confirm': '建立 CRDT 副本',
|
||||
'cloudSync.convergent.preview.status.ready': '就緒',
|
||||
'cloudSync.convergent.preview.status.empty': '空白',
|
||||
'cloudSync.convergent.preview.status.unavailable': '無法使用',
|
||||
'cloudSync.convergent.preview.status.blocked': '已封鎖',
|
||||
'cloudSync.convergent.preview.schema': '協定版本',
|
||||
'cloudSync.convergent.field.presence': '存在狀態',
|
||||
'cloudSync.convergent.field.position': '排序位置',
|
||||
'cloudSync.convergent.conflicts.title': '欄位衝突({count})',
|
||||
'cloudSync.convergent.conflict.empty': '空白 / 已刪除',
|
||||
'cloudSync.convergent.conflict.secretSet': '秘密值已設定',
|
||||
'cloudSync.convergent.conflict.current': '目前候選',
|
||||
'cloudSync.convergent.conflict.choose': '選擇',
|
||||
'cloudSync.convergent.conflict.resolved': '衝突已解決並同步',
|
||||
'cloudSync.convergent.downgrade.desc': '將所有已連線雲端服務中的 v2 檔案替換為舊版快照。',
|
||||
'cloudSync.convergent.downgrade.button': '降級',
|
||||
'cloudSync.convergent.downgrade.confirm': '確定將所有已連線雲端服務降級為舊版同步嗎?寫入驗證通過後會移除 CRDT 中繼資料。',
|
||||
'cloudSync.convergent.downgrade.done': '可收斂同步已降級',
|
||||
'cloudSync.status.title': '同步狀態',
|
||||
'cloudSync.status.localVersion': '本機版本',
|
||||
'cloudSync.status.remoteVersion': '遠端版本',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
clear: () => localStorageValues.clear(),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
applyProtectedSyncPayload,
|
||||
readInterruptedVaultApply,
|
||||
} = await import('./localVaultBackups.ts');
|
||||
|
||||
function emptyPayload(): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('protected apply preparation failure does not leave a partial-apply sentinel', async () => {
|
||||
let snapshotBuilt = false;
|
||||
|
||||
await assert.rejects(
|
||||
() => applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => {
|
||||
snapshotBuilt = true;
|
||||
return emptyPayload();
|
||||
},
|
||||
prepareApply: async () => {
|
||||
throw new Error('replica unavailable');
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
}),
|
||||
/replica unavailable/,
|
||||
);
|
||||
|
||||
assert.equal(snapshotBuilt, false);
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
});
|
||||
|
||||
test('prepared apply callback runs after the snapshot with sentinel protection', async () => {
|
||||
const calls: string[] = [];
|
||||
let observedSentinel = false;
|
||||
|
||||
await applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => {
|
||||
calls.push('snapshot');
|
||||
return emptyPayload();
|
||||
},
|
||||
prepareApply: async () => {
|
||||
calls.push('prepare');
|
||||
return async () => {
|
||||
calls.push('apply');
|
||||
observedSentinel = readInterruptedVaultApply() !== null;
|
||||
};
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['prepare', 'snapshot', 'apply']);
|
||||
assert.equal(observedSentinel, true);
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
});
|
||||
@@ -387,17 +387,34 @@ function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void
|
||||
* current vault. Callers pass their own React-closure builder (hosts,
|
||||
* keys, port-forwarding rules) because the caller owns that state.
|
||||
*
|
||||
* `prepareApply`, when provided, runs while the restore barrier is held but
|
||||
* before the protective backup and apply-in-progress sentinel. It must not
|
||||
* mutate local vault state; it returns the callback that performs the actual
|
||||
* apply. This lets callers validate external state without leaving a false
|
||||
* partial-apply sentinel when validation fails.
|
||||
*
|
||||
* `translateProtectiveBackupFailure` converts the
|
||||
* `ProtectiveBackupUnavailableError` into a user-visible message in the
|
||||
* caller's locale. It runs only on the thrown-and-caught path.
|
||||
*/
|
||||
export function applyProtectedSyncPayload(options: {
|
||||
type ProtectedApplyCallback = () => void | Promise<void>;
|
||||
|
||||
export type ApplyProtectedSyncPayloadOptions = {
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: () => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
}): Promise<void> {
|
||||
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
|
||||
} & (
|
||||
| { applyPayload: ProtectedApplyCallback; prepareApply?: never }
|
||||
| { applyPayload?: never; prepareApply: () => Promise<ProtectedApplyCallback> }
|
||||
);
|
||||
|
||||
export function applyProtectedSyncPayload(
|
||||
options: ApplyProtectedSyncPayloadOptions,
|
||||
): Promise<void> {
|
||||
const { buildPreApplyPayload, translateProtectiveBackupFailure } = options;
|
||||
return withRestoreBarrier(async () => {
|
||||
const applyPayload = options.prepareApply
|
||||
? await options.prepareApply()
|
||||
: options.applyPayload;
|
||||
const pre = buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupSdkAgentSessions,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
const sdkCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const terminalOutputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (chatSessionId: string) => {
|
||||
sdkCleanups.push(chatSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (chatSessionId: string) => {
|
||||
outputCleanups.push(chatSessionId);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
deleteTerminalToolOutputsEverywhereTemp: async (terminalSessionId: string) => {
|
||||
terminalOutputCleanups.push(terminalSessionId);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupSdkAgentSessions(['history-kept']);
|
||||
cleanupDeletedAIChatSessions(['history-deleted']);
|
||||
cleanupClosedTerminalSessions(['terminal-closed', 'terminal-closed']);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(sdkCleanups, ['history-kept', 'history-deleted']);
|
||||
assert.deepEqual(outputCleanups, ['history-deleted']);
|
||||
assert.deepEqual(terminalOutputCleanups, ['terminal-closed']);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -20,10 +20,13 @@ import {
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
|
||||
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
@@ -44,9 +47,27 @@ export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
|
||||
if (sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
getAgentRuntime().clearChatSession(sessionId);
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
for (const terminalSessionId of new Set(terminalSessionIds)) {
|
||||
getAgentRuntime().clearTerminalSession(terminalSessionId);
|
||||
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceStorageEvent,
|
||||
resolveAppearanceSyncState,
|
||||
resolveIncomingAppearanceValue,
|
||||
type AppearanceState,
|
||||
type AppearanceRenderSnapshot,
|
||||
type StoredAppearanceValues,
|
||||
} from "./appearanceSync.ts";
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
|
||||
const systemLight: AppearanceRenderSnapshot = {
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
|
||||
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
|
||||
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
|
||||
let storedTheme: "light" | "dark" | "system" = "dark";
|
||||
|
||||
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
|
||||
storedTheme = systemDark.theme;
|
||||
}
|
||||
|
||||
assert.equal(storedTheme, "dark");
|
||||
});
|
||||
|
||||
test("an explicit theme choice remains a persisted appearance change", () => {
|
||||
assert.equal(
|
||||
hasPersistedAppearanceChanged(systemLight, {
|
||||
...systemLight,
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("an appearance IPC value wins over stale local storage for the changed key", () => {
|
||||
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
|
||||
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_THEME,
|
||||
"system",
|
||||
"system",
|
||||
(value): value is "light" | "dark" | "system" => (
|
||||
value === "light" || value === "dark" || value === "system"
|
||||
),
|
||||
),
|
||||
"dark",
|
||||
);
|
||||
// Non-matching keyed IPC must keep the current in-memory value, not storage.
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
"stale-from-storage",
|
||||
"midnight",
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
"midnight",
|
||||
);
|
||||
// Full rehydrate (no incoming) still prefers storage.
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
undefined,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
"from-storage",
|
||||
"midnight",
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
"from-storage",
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
|
||||
const current: AppearanceState = {
|
||||
theme: "dark",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
// Storage still lags for every non-theme field (the race the review covers).
|
||||
const staleStored: StoredAppearanceValues = {
|
||||
theme: "dark",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
|
||||
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
|
||||
// announced on the IPC payload, so the reducer must prefer the payload.
|
||||
const darkUiSelection = {
|
||||
key: STORAGE_KEY_UI_THEME_DARK,
|
||||
value: "github",
|
||||
};
|
||||
const nextDarkUi = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
darkUiThemeId: "midnight",
|
||||
}, darkUiSelection);
|
||||
assert.equal(nextDarkUi.darkUiThemeId, "github");
|
||||
assert.equal(nextDarkUi.theme, "dark");
|
||||
assert.equal(nextDarkUi.lightUiThemeId, "snow");
|
||||
|
||||
const lightUiSelection = {
|
||||
key: STORAGE_KEY_UI_THEME_LIGHT,
|
||||
value: "flexoki",
|
||||
};
|
||||
const nextLightUi = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
lightUiThemeId: "snow",
|
||||
}, lightUiSelection);
|
||||
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
|
||||
|
||||
const accentModeSelection = {
|
||||
key: STORAGE_KEY_ACCENT_MODE,
|
||||
value: "custom",
|
||||
};
|
||||
const nextAccentMode = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
accentMode: "theme",
|
||||
}, accentModeSelection);
|
||||
assert.equal(nextAccentMode.accentMode, "custom");
|
||||
|
||||
const customAccentSelection = {
|
||||
key: STORAGE_KEY_COLOR,
|
||||
value: "221.2 83.2% 53.3%",
|
||||
};
|
||||
const nextCustomAccent = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
customAccent: "208 100% 50%",
|
||||
}, customAccentSelection);
|
||||
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
|
||||
|
||||
// Without an announced key, full rehydrate still reads storage.
|
||||
const noIncoming = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
darkUiThemeId: "github",
|
||||
});
|
||||
assert.equal(noIncoming.darkUiThemeId, "github");
|
||||
});
|
||||
|
||||
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
|
||||
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
|
||||
// Storage still holds the pre-change values for the entire sequence.
|
||||
const initial: AppearanceState = {
|
||||
theme: "system",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
const staleStored: StoredAppearanceValues = { ...initial };
|
||||
|
||||
let next = resolveAppearanceSyncState(initial, staleStored, {
|
||||
key: STORAGE_KEY_THEME,
|
||||
value: "dark",
|
||||
});
|
||||
assert.equal(next.theme, "dark");
|
||||
assert.equal(next.darkUiThemeId, "midnight");
|
||||
|
||||
// Later theme-id message must not revert theme back to the stale stored System.
|
||||
next = resolveAppearanceSyncState(next, staleStored, {
|
||||
key: STORAGE_KEY_UI_THEME_DARK,
|
||||
value: "github",
|
||||
});
|
||||
assert.equal(next.theme, "dark");
|
||||
assert.equal(next.darkUiThemeId, "github");
|
||||
assert.equal(next.lightUiThemeId, "snow");
|
||||
assert.equal(next.accentMode, "theme");
|
||||
});
|
||||
|
||||
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
|
||||
const initialState: AppearanceState = {
|
||||
theme: systemLight.theme,
|
||||
lightUiThemeId: systemLight.lightUiThemeId,
|
||||
darkUiThemeId: systemLight.darkUiThemeId,
|
||||
accentMode: systemLight.accentMode,
|
||||
customAccent: systemLight.customAccent,
|
||||
};
|
||||
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
|
||||
resolveGlobalTerminalAppearance({
|
||||
userIntent: idleThemeUserIntent(),
|
||||
settings: {
|
||||
terminalThemeId: "netcatty-dark",
|
||||
terminalThemeDarkId: "auto",
|
||||
terminalThemeLightId: "auto",
|
||||
followAppTerminalTheme: true,
|
||||
resolvedTheme,
|
||||
lightUiThemeId: appearance.lightUiThemeId,
|
||||
darkUiThemeId: appearance.darkUiThemeId,
|
||||
accentMode: appearance.accentMode,
|
||||
customAccent: appearance.customAccent,
|
||||
},
|
||||
customThemes: [],
|
||||
})
|
||||
);
|
||||
const persistRender = (
|
||||
stored: StoredAppearanceValues,
|
||||
previous: AppearanceRenderSnapshot,
|
||||
current: AppearanceRenderSnapshot,
|
||||
): StoredAppearanceValues => {
|
||||
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
|
||||
return {
|
||||
theme: current.theme,
|
||||
lightUiThemeId: current.lightUiThemeId,
|
||||
darkUiThemeId: current.darkUiThemeId,
|
||||
accentMode: current.accentMode,
|
||||
customAccent: current.customAccent,
|
||||
};
|
||||
};
|
||||
|
||||
let stored: StoredAppearanceValues = { ...initialState };
|
||||
let main = { ...initialState };
|
||||
let detachedTerminal = { ...initialState };
|
||||
const initialMainTerminal = terminalAppearance(main, "light");
|
||||
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
|
||||
|
||||
const settingsDark: AppearanceRenderSnapshot = {
|
||||
...systemLight,
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
};
|
||||
stored = persistRender(stored, systemLight, settingsDark);
|
||||
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
|
||||
|
||||
// A stale peer receives an OS color event before the Dark IPC message. Its
|
||||
// semantic choice is still System, so it must not write System back.
|
||||
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
|
||||
|
||||
// Main windows run the production IPC reducer. Deliberately pass its lagging
|
||||
// System storage read so only the ordered Dark payload can win.
|
||||
main = resolveAppearanceSyncState(main, {
|
||||
...stored,
|
||||
theme: "system",
|
||||
}, darkSelection);
|
||||
|
||||
// Detached terminal and tray renderers run the production storage-event
|
||||
// reducer because they are not direct IPC broadcast targets.
|
||||
const detachedUpdate = resolveAppearanceStorageEvent(
|
||||
detachedTerminal,
|
||||
darkSelection.key,
|
||||
String(stored.theme),
|
||||
);
|
||||
detachedTerminal = detachedUpdate.next;
|
||||
|
||||
const finalMainTerminal = terminalAppearance(main, "dark");
|
||||
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
|
||||
|
||||
assert.equal(stored.theme, "dark");
|
||||
assert.equal(settingsDark.theme, "dark");
|
||||
assert.equal(main.theme, "dark");
|
||||
assert.equal(detachedUpdate.handled, true);
|
||||
assert.equal(detachedTerminal.theme, "dark");
|
||||
assert.equal(initialMainTerminal.theme.type, "light");
|
||||
assert.equal(initialDetachedTerminal.theme.type, "light");
|
||||
assert.equal(finalMainTerminal.theme.type, "dark");
|
||||
assert.equal(finalDetachedTerminal.theme.type, "dark");
|
||||
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
|
||||
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
|
||||
});
|
||||
|
||||
test("the race guards are wired into the real settings paths", () => {
|
||||
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
|
||||
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
|
||||
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
|
||||
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
|
||||
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
|
||||
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
|
||||
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
|
||||
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
|
||||
|
||||
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
|
||||
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
|
||||
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
|
||||
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
|
||||
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
|
||||
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
|
||||
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
|
||||
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
|
||||
);
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
|
||||
);
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
|
||||
);
|
||||
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
|
||||
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
|
||||
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
|
||||
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
|
||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiThemeId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
export type AppearanceState = {
|
||||
theme: "light" | "dark" | "system";
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: "theme" | "custom";
|
||||
customAccent: string;
|
||||
};
|
||||
|
||||
export type AppearanceRenderSnapshot = {
|
||||
theme: AppearanceState["theme"];
|
||||
resolvedTheme: "light" | "dark";
|
||||
} & Omit<AppearanceState, "theme">;
|
||||
|
||||
export type AppearanceSyncEvent = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type StoredAppearanceValues = {
|
||||
theme: unknown;
|
||||
lightUiThemeId: unknown;
|
||||
darkUiThemeId: unknown;
|
||||
accentMode: unknown;
|
||||
customAccent: unknown;
|
||||
};
|
||||
|
||||
export type AppearanceStorageEventResolution = {
|
||||
handled: boolean;
|
||||
next: AppearanceState;
|
||||
};
|
||||
|
||||
export function hasPersistedAppearanceChanged(
|
||||
previous: AppearanceRenderSnapshot,
|
||||
current: AppearanceRenderSnapshot,
|
||||
): boolean {
|
||||
return previous.theme !== current.theme
|
||||
|| previous.lightUiThemeId !== current.lightUiThemeId
|
||||
|| previous.darkUiThemeId !== current.darkUiThemeId
|
||||
|| previous.accentMode !== current.accentMode
|
||||
|| previous.customAccent !== current.customAccent;
|
||||
}
|
||||
|
||||
export function resolveIncomingAppearanceValue<T>(
|
||||
incoming: AppearanceSyncEvent | undefined,
|
||||
key: string,
|
||||
storedValue: T,
|
||||
currentValue: T,
|
||||
isValid: (value: unknown) => value is T,
|
||||
): T {
|
||||
if (incoming?.key === key && isValid(incoming.value)) {
|
||||
return incoming.value;
|
||||
}
|
||||
// Keyed IPC updates only trust the announced key. Non-matching fields keep
|
||||
// the in-memory current value so sequential notifies for one multi-field
|
||||
// change cannot clobber each other with a still-stale storage read.
|
||||
// Full rehydrate (no incoming) continues to prefer shared storage.
|
||||
if (incoming) {
|
||||
return currentValue;
|
||||
}
|
||||
return storedValue;
|
||||
}
|
||||
|
||||
export function resolveAppearanceSyncState(
|
||||
current: AppearanceState,
|
||||
stored: StoredAppearanceValues,
|
||||
incoming?: AppearanceSyncEvent,
|
||||
): AppearanceState {
|
||||
const theme = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_THEME,
|
||||
stored.theme,
|
||||
current.theme,
|
||||
isValidTheme,
|
||||
);
|
||||
const lightUiThemeId = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
stored.lightUiThemeId,
|
||||
current.lightUiThemeId,
|
||||
(value): value is string => typeof value === 'string' && isValidUiThemeId('light', value),
|
||||
);
|
||||
const darkUiThemeId = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
stored.darkUiThemeId,
|
||||
current.darkUiThemeId,
|
||||
(value): value is string => typeof value === 'string' && isValidUiThemeId('dark', value),
|
||||
);
|
||||
const accentMode = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
stored.accentMode,
|
||||
current.accentMode,
|
||||
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
|
||||
);
|
||||
const customAccent = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_COLOR,
|
||||
stored.customAccent,
|
||||
current.customAccent,
|
||||
(value): value is string => typeof value === 'string' && isValidHslToken(value),
|
||||
);
|
||||
|
||||
return {
|
||||
theme: isValidTheme(theme) ? theme : current.theme,
|
||||
lightUiThemeId: typeof lightUiThemeId === 'string' && isValidUiThemeId('light', lightUiThemeId)
|
||||
? lightUiThemeId
|
||||
: current.lightUiThemeId,
|
||||
darkUiThemeId: typeof darkUiThemeId === 'string' && isValidUiThemeId('dark', darkUiThemeId)
|
||||
? darkUiThemeId
|
||||
: current.darkUiThemeId,
|
||||
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
|
||||
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
|
||||
? customAccent.trim()
|
||||
: current.customAccent,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAppearanceStorageEvent(
|
||||
current: AppearanceState,
|
||||
key: string | null,
|
||||
newValue: string | null,
|
||||
): AppearanceStorageEventResolution {
|
||||
if (key === STORAGE_KEY_THEME) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidTheme(newValue) ? { ...current, theme: newValue } : current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_THEME_LIGHT) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidUiThemeId('light', newValue)
|
||||
? { ...current, lightUiThemeId: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_THEME_DARK) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidUiThemeId('dark', newValue)
|
||||
? { ...current, darkUiThemeId: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_ACCENT_MODE) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue === 'theme' || newValue === 'custom'
|
||||
? { ...current, accentMode: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_COLOR) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidHslToken(newValue)
|
||||
? { ...current, customAccent: newValue.trim() }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
return { handled: false, next: current };
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
clearReferenceKeyPassphrases,
|
||||
loadDefaultKeyPassphrase,
|
||||
rememberKeyPassphrase,
|
||||
removeDefaultKeyPassphraseAliases,
|
||||
saveDefaultKeyPassphrase,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from "../defaultKeyPassphrases";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
|
||||
@@ -79,6 +81,73 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJj" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
||||
await saveDefaultKeyPassphrase("/Users/alice/.ssh/id_new", "new-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.equal(await pendingLoad, null);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_new"),
|
||||
"new-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase retries when the same path is saved concurrently", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "old-passphrase" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase(keyPath);
|
||||
await saveDefaultKeyPassphrase(keyPath, "new-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.equal(await pendingLoad, "new-passphrase");
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
@@ -90,6 +159,266 @@ test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
|
||||
});
|
||||
|
||||
test("saveDefaultKeyPassphrase makes the passphrase available to the connection prompt", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
|
||||
await saveDefaultKeyPassphrase(keyPath, "saved by agent");
|
||||
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "saved by agent");
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase matches an expanded connection path to a saved home-relative path", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => "/Users/alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "saved by agent");
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"saved by agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"valid-exact-passphrase",
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invalid exact value", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"valid-alias-passphrase",
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "~/.ssh/id_ed25519": "valid-alias-passphrase" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase consolidates conflicting valid aliases around the exact path", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "new-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"new-exact-passphrase",
|
||||
);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("~/.ssh/id_ed25519"),
|
||||
"new-exact-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("saveDefaultKeyPassphrase replaces stale values stored under path aliases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-relative",
|
||||
"/Users/alice/.ssh/id_ed25519": "old-absolute",
|
||||
}),
|
||||
);
|
||||
|
||||
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "replacement");
|
||||
|
||||
const stored = JSON.parse(
|
||||
globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}",
|
||||
) as Record<string, string>;
|
||||
assert.equal(stored["/Users/alice/.ssh/id_ed25519"], undefined);
|
||||
assert.equal(await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"), "replacement");
|
||||
});
|
||||
|
||||
test("removeDefaultKeyPassphraseAliases clears relative and expanded paths", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-relative",
|
||||
"/Users/alice/.ssh/id_ed25519": "old-absolute",
|
||||
"/Users/alice/.ssh/other": "keep",
|
||||
}),
|
||||
);
|
||||
|
||||
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
|
||||
|
||||
assert.deepEqual(new Set(aliases), new Set([
|
||||
"~/.ssh/id_ed25519",
|
||||
"/Users/alice/.ssh/id_ed25519",
|
||||
]));
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/other": "keep" },
|
||||
);
|
||||
const clearedKeys = clearReferenceKeyPassphrases([
|
||||
{ ...referenceKey(), passphrase: "old", savePassphrase: true },
|
||||
], aliases);
|
||||
assert.equal(clearedKeys[0].passphrase, undefined);
|
||||
assert.equal(clearedKeys[0].savePassphrase, false);
|
||||
});
|
||||
|
||||
test("removeDefaultKeyPassphraseAliases preserves a corrected passphrase saved during alias lookup", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "old-passphrase" }),
|
||||
);
|
||||
|
||||
const pendingRemoval = removeDefaultKeyPassphraseAliases([keyPath]);
|
||||
await saveDefaultKeyPassphrase(keyPath, "corrected-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.deepEqual(await pendingRemoval, []);
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "corrected-passphrase");
|
||||
});
|
||||
|
||||
test("rememberKeyPassphrase updates a reference key stored under an expanded alias", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
let updatedKeys: SSHKey[] | undefined;
|
||||
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "replacement",
|
||||
keys: [{ ...referenceKey(), passphrase: "old", savePassphrase: true }],
|
||||
updateKeys: (keys) => {
|
||||
updatedKeys = keys;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updatedKeys?.[0].passphrase, "replacement");
|
||||
assert.equal(updatedKeys?.[0].savePassphrase, true);
|
||||
});
|
||||
|
||||
test("path aliases replace and clear Windows reference-key spellings", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "C:\\Users\\Alice" } },
|
||||
});
|
||||
const windowsReferenceKey: SSHKey = {
|
||||
...referenceKey(),
|
||||
filePath: "c:\\users\\alice\\.ssh\\id_ed25519",
|
||||
passphrase: "old",
|
||||
savePassphrase: true,
|
||||
};
|
||||
let updatedKeys: SSHKey[] | undefined;
|
||||
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "replacement",
|
||||
keys: [windowsReferenceKey],
|
||||
updateKeys: (keys) => {
|
||||
updatedKeys = keys;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updatedKeys?.[0].passphrase, "replacement");
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("C:\\Users\\Alice\\.ssh\\id_ed25519"),
|
||||
"replacement",
|
||||
);
|
||||
|
||||
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
|
||||
const clearedKeys = clearReferenceKeyPassphrases(updatedKeys ?? [], aliases);
|
||||
assert.equal(clearedKeys[0].passphrase, undefined);
|
||||
assert.equal(await loadDefaultKeyPassphrase("c:\\users\\alice\\.ssh\\id_ed25519"), null);
|
||||
});
|
||||
|
||||
test("POSIX backslashes remain distinct from path separators", async (t) => {
|
||||
installLocalStorage(t);
|
||||
|
||||
await saveDefaultKeyPassphrase("/home/alice/.ssh/team\\key", "backslash-name");
|
||||
await saveDefaultKeyPassphrase("/home/alice/.ssh/team/key", "nested-path");
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/home/alice/.ssh/team\\key"),
|
||||
"backslash-name",
|
||||
);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/home/alice/.ssh/team/key"),
|
||||
"nested-path",
|
||||
);
|
||||
});
|
||||
|
||||
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
|
||||
const keys: SSHKey[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./fontStore.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('refresh clears local font and availability detection caches', () => {
|
||||
assert.match(source, /clearLocalFontsCache\(\);\s*clearFontAvailabilityCache\(\);/);
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
|
||||
import { setSystemFamilies } from '../../lib/fontAvailability';
|
||||
import {
|
||||
clearLocalFontsCache,
|
||||
getAllSystemFontFamilyNames,
|
||||
getMonospaceFonts,
|
||||
} from '../../lib/localFonts';
|
||||
import {
|
||||
clearFontAvailabilityCache,
|
||||
setSystemFamilies,
|
||||
} from '../../lib/fontAvailability';
|
||||
|
||||
/**
|
||||
* Global font store - singleton pattern using useSyncExternalStore
|
||||
@@ -11,6 +18,7 @@ type Listener = () => void;
|
||||
|
||||
interface FontStoreState {
|
||||
availableFonts: TerminalFont[];
|
||||
installedFontFamilies: string[] | null;
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: string | null;
|
||||
@@ -19,6 +27,7 @@ interface FontStoreState {
|
||||
class FontStore {
|
||||
private state: FontStoreState = {
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
installedFontFamilies: null,
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
@@ -27,6 +36,7 @@ class FontStore {
|
||||
|
||||
// Getters for individual state slices
|
||||
getAvailableFonts = (): TerminalFont[] => this.state.availableFonts;
|
||||
getInstalledFontFamilies = (): string[] | null => this.state.installedFontFamilies;
|
||||
getIsLoading = (): boolean => this.state.isLoading;
|
||||
getIsLoaded = (): boolean => this.state.isLoaded;
|
||||
getError = (): string | null => this.state.error;
|
||||
@@ -64,12 +74,16 @@ class FontStore {
|
||||
// Populate the authoritative installed-family set used by
|
||||
// fontAvailability.isFontInstalled. Runs in parallel with the
|
||||
// monospace-only query (both share an underlying cache).
|
||||
const [localFonts, systemFamilies] = await Promise.all([
|
||||
const [localFonts, installedFontFamilies] = await Promise.all([
|
||||
getMonospaceFonts(),
|
||||
getAllSystemFontFamilies(),
|
||||
getAllSystemFontFamilyNames(),
|
||||
]);
|
||||
setSystemFamilies(systemFamilies);
|
||||
|
||||
setSystemFamilies(
|
||||
installedFontFamilies
|
||||
? new Set(installedFontFamilies.map((family) => family.toLowerCase()))
|
||||
: null,
|
||||
);
|
||||
|
||||
// Combine default fonts with local fonts, deduplicate by id
|
||||
const fontMap = new Map<string, TerminalFont>();
|
||||
|
||||
@@ -90,6 +104,7 @@ class FontStore {
|
||||
|
||||
this.setState({
|
||||
availableFonts: Array.from(fontMap.values()),
|
||||
installedFontFamilies,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
});
|
||||
@@ -98,6 +113,7 @@ class FontStore {
|
||||
console.warn('Failed to fetch local fonts, using defaults:', error);
|
||||
this.setState({
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
installedFontFamilies: null,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: errorMessage,
|
||||
@@ -105,6 +121,14 @@ class FontStore {
|
||||
}
|
||||
};
|
||||
|
||||
refresh = async (): Promise<void> => {
|
||||
if (this.state.isLoading) return;
|
||||
clearLocalFontsCache();
|
||||
clearFontAvailabilityCache();
|
||||
this.setState({ isLoaded: false });
|
||||
await this.initialize();
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a font by ID with fallback
|
||||
*/
|
||||
@@ -134,6 +158,23 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useInstalledFontFamilies = (): string[] | null => {
|
||||
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
|
||||
fontStore.initialize();
|
||||
}
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
);
|
||||
};
|
||||
|
||||
export const useFontsLoading = (): boolean => useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getIsLoading,
|
||||
);
|
||||
|
||||
export const refreshFonts = (): Promise<void> => fontStore.refresh();
|
||||
|
||||
/**
|
||||
* Initialize fonts eagerly (call at app startup)
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { createHostTerminalSession } from "./sessionFactories";
|
||||
import { createHostTerminalSession, createSerialTerminalSession } from "./sessionFactories";
|
||||
|
||||
const host = (overrides: Partial<Host>): Host => ({
|
||||
id: "host-1",
|
||||
@@ -33,3 +35,59 @@ test("createHostTerminalSession keeps telnet deep-link default port for ssh host
|
||||
assert.equal(session.protocol, "telnet");
|
||||
assert.equal(session.port, 23);
|
||||
});
|
||||
|
||||
test("serial session factories snapshot effective legacy Backspace behavior", () => {
|
||||
const savedHostSession = createHostTerminalSession("session-1", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
port: 115200,
|
||||
username: "",
|
||||
serialConfig: {
|
||||
path: "COM3",
|
||||
baudRate: 115200,
|
||||
},
|
||||
backspaceBehavior: "ctrl-h",
|
||||
}));
|
||||
const quickSession = createSerialTerminalSession("session-2", {
|
||||
path: "COM4",
|
||||
baudRate: 9600,
|
||||
});
|
||||
const explicitDefaultSession = createHostTerminalSession("session-3", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM5",
|
||||
port: 115200,
|
||||
username: "",
|
||||
backspaceBehavior: "ctrl-h",
|
||||
serialConfig: {
|
||||
path: "COM5",
|
||||
baudRate: 115200,
|
||||
backspaceBehavior: "default",
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(savedHostSession.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||
assert.equal(quickSession.serialConfig?.backspaceBehavior, "default");
|
||||
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
||||
});
|
||||
|
||||
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
port: 115200,
|
||||
username: "",
|
||||
group: "network/serial",
|
||||
serialConfig: prepareSerialConfigForSavedHost({
|
||||
path: "COM3",
|
||||
baudRate: 115200,
|
||||
backspaceBehavior: "default",
|
||||
}),
|
||||
});
|
||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
});
|
||||
|
||||
const session = createHostTerminalSession("session-1", effectiveHost);
|
||||
|
||||
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||
});
|
||||
@@ -37,21 +37,31 @@ export const createLocalTerminalSession = (
|
||||
localStartDir: options?.localStartDir,
|
||||
});
|
||||
|
||||
export const snapshotSerialConfig = (
|
||||
config: SerialConfig,
|
||||
legacyBackspaceBehavior?: Host["backspaceBehavior"],
|
||||
): SerialConfig => ({
|
||||
...config,
|
||||
backspaceBehavior: config.backspaceBehavior
|
||||
?? (legacyBackspaceBehavior === "ctrl-h" ? "ctrl-h" : "default"),
|
||||
});
|
||||
|
||||
export const createSerialTerminalSession = (
|
||||
sessionId: string,
|
||||
config: SerialConfig,
|
||||
options?: { charset?: string },
|
||||
): TerminalSession => {
|
||||
const portName = config.path.split("/").pop() || config.path;
|
||||
const serialConfig = snapshotSerialConfig(config);
|
||||
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: `serial-${sessionId}`,
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
hostname: serialConfig.path,
|
||||
username: "",
|
||||
status: "connecting",
|
||||
protocol: "serial",
|
||||
serialConfig: config,
|
||||
serialConfig,
|
||||
charset: options?.charset,
|
||||
};
|
||||
};
|
||||
@@ -61,16 +71,19 @@ export const createHostTerminalSession = (
|
||||
host: Host,
|
||||
): TerminalSession => {
|
||||
if (host.protocol === "serial") {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
flowControl: "none",
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const serialConfig = snapshotSerialConfig(
|
||||
host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
flowControl: "none",
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
},
|
||||
host.backspaceBehavior,
|
||||
);
|
||||
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
|
||||
return {
|
||||
id: sessionId,
|
||||
|
||||
@@ -2,9 +2,12 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
applyCloseSessionToSessions,
|
||||
closeSessionsState,
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
resolveActiveTabAfterCloseSession,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
@@ -121,3 +124,94 @@ test("closing a workspace session dissolves the workspace when one terminal rema
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1"), session("s2")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
closedSessionId: "s1",
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
"s2",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions, []);
|
||||
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||||
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
closedSessionId: "s1",
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
"vault",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing several standalone sessions removes the whole batch", () => {
|
||||
const standaloneSession = (id: string): TerminalSession => ({
|
||||
...session(id),
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
const result = closeSessionsState({
|
||||
sessions: [standaloneSession("s1"), standaloneSession("s2"), standaloneSession("s3")],
|
||||
workspaces: [],
|
||||
sessionIds: ["s1", "s2", "s3"],
|
||||
currentActiveTabId: "s1",
|
||||
tabOrder: ["s1", "s2", "s3"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.equal(result.activeTabId, "vault");
|
||||
});
|
||||
|
||||
test("closing a subset of standalone sessions keeps tabs outside the batch", () => {
|
||||
const standaloneSession = (id: string): TerminalSession => ({
|
||||
...session(id),
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
const result = closeSessionsState({
|
||||
sessions: [standaloneSession("s1"), standaloneSession("s2"), standaloneSession("s3")],
|
||||
workspaces: [],
|
||||
sessionIds: ["s2", "s3"],
|
||||
currentActiveTabId: "s1",
|
||||
tabOrder: ["s1", "s2", "s3"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1"]);
|
||||
assert.equal(result.activeTabId, undefined);
|
||||
});
|
||||
@@ -17,6 +17,13 @@ export type CloseSessionWorkspaceLayoutResult = {
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
export type CloseSessionsStateResult = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
@@ -101,6 +108,120 @@ export function closeSessionWorkspaceLayoutState(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a close-session action to the session list using the layout result.
|
||||
* When a 2-pane workspace dissolves to one terminal, that remaining session
|
||||
* must become an orphan tab — not stay bound to a deleted workspaceId.
|
||||
*/
|
||||
export function applyCloseSessionToSessions(
|
||||
sessions: readonly TerminalSession[],
|
||||
sessionId: string,
|
||||
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
||||
): TerminalSession[] {
|
||||
const remaining = sessions.filter((session) => session.id !== sessionId);
|
||||
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
|
||||
if (!lastRemainingSessionId) return remaining;
|
||||
|
||||
return remaining.map((session) => (
|
||||
session.id === lastRemainingSessionId
|
||||
? { ...session, workspaceId: undefined }
|
||||
: session
|
||||
));
|
||||
}
|
||||
|
||||
export function resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId,
|
||||
closedSessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
}: {
|
||||
currentActiveTabId: string | null;
|
||||
closedSessionId: string;
|
||||
workspaceId: string | undefined;
|
||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||
remainingSessions: readonly TerminalSession[];
|
||||
}): string | null {
|
||||
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId).slice(-1)[0];
|
||||
const fallback = layoutResult.lastRemainingSessionId
|
||||
?? fallbackWorkspace?.id
|
||||
?? fallbackSolo?.id
|
||||
?? "vault";
|
||||
|
||||
if (
|
||||
currentActiveTabId === closedSessionId
|
||||
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|
||||
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|
||||
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function closeSessionsState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionIds,
|
||||
currentActiveTabId,
|
||||
tabOrder,
|
||||
}: {
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
sessionIds: readonly string[];
|
||||
currentActiveTabId: string | null;
|
||||
tabOrder: readonly string[];
|
||||
}): CloseSessionsStateResult {
|
||||
let nextSessions = [...sessions];
|
||||
let nextWorkspaces = [...workspaces];
|
||||
let nextTabOrder = [...tabOrder];
|
||||
let nextActiveTabId = currentActiveTabId;
|
||||
let resolvedActiveTabId: string | undefined;
|
||||
|
||||
for (const sessionId of new Set(sessionIds)) {
|
||||
const targetSession = nextSessions.find((session) => session.id === sessionId);
|
||||
if (!targetSession) continue;
|
||||
|
||||
const workspaceId = targetSession.workspaceId;
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
nextWorkspaces,
|
||||
workspaceId,
|
||||
sessionId,
|
||||
);
|
||||
nextWorkspaces = layoutResult.workspaces;
|
||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
|
||||
|
||||
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
|
||||
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
nextTabOrder,
|
||||
layoutResult.dissolvedWorkspaceId,
|
||||
[layoutResult.lastRemainingSessionId],
|
||||
);
|
||||
}
|
||||
|
||||
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: nextActiveTabId,
|
||||
closedSessionId: sessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
});
|
||||
if (activeTabAfterClose) {
|
||||
nextActiveTabId = activeTabAfterClose;
|
||||
resolvedActiveTabId = activeTabAfterClose;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: nextSessions,
|
||||
workspaces: nextWorkspaces,
|
||||
tabOrder: nextTabOrder,
|
||||
activeTabId: resolvedActiveTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
|
||||
@@ -58,10 +58,11 @@ import {
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import type { AppearanceSyncEvent } from './appearanceSync';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
syncAppearanceFromStorage: () => void;
|
||||
syncAppearanceFromStorage: (incoming?: AppearanceSyncEvent) => void;
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
@@ -156,7 +157,7 @@ export function useSettingsIpcSync({
|
||||
key === STORAGE_KEY_ACCENT_MODE ||
|
||||
key === STORAGE_KEY_COLOR
|
||||
) {
|
||||
syncAppearanceFromStorage();
|
||||
syncAppearanceFromStorage({ key, value });
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
|
||||
@@ -76,6 +76,8 @@ export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
export const DEFAULT_SFTP_FOLLOW_TERMINAL_CWD = false;
|
||||
export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export { DEFAULT_HOST_CLICK_BEHAVIOR } from '../../domain/hostClickBehavior';
|
||||
export type { HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSetting
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
@@ -43,24 +42,20 @@ import {
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { resolveAppearanceStorageEvent } from './appearanceSync';
|
||||
import {
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isHostClickBehavior, type HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -85,6 +80,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
@@ -129,6 +125,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -163,7 +160,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -172,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -186,7 +183,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -196,7 +193,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -206,30 +203,14 @@ export function useSettingsStorageSync({
|
||||
if (!enabled) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
const appearance = resolveAppearanceStorageEvent(s, e.key, e.newValue);
|
||||
if (appearance.handled) {
|
||||
if (appearance.next.theme !== s.theme) setTheme(appearance.next.theme);
|
||||
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
|
||||
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
|
||||
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
|
||||
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
|
||||
return;
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
@@ -405,6 +386,11 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOST_CLICK_BEHAVIOR && e.newValue !== null) {
|
||||
if (isHostClickBehavior(e.newValue) && e.newValue !== s.hostClickBehavior) {
|
||||
setHostClickBehaviorState(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
@@ -510,6 +496,7 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setHostClickBehaviorState,
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { sftpPaneViewModeStore } from './sftpPaneViewModeStore.ts';
|
||||
|
||||
test('SFTP pane view mode store distinguishes an empty list from tree view', () => {
|
||||
const paneId = 'empty-list-pane';
|
||||
assert.equal(sftpPaneViewModeStore.get(paneId), 'list');
|
||||
|
||||
sftpPaneViewModeStore.set(paneId, 'tree');
|
||||
assert.equal(sftpPaneViewModeStore.get(paneId), 'tree');
|
||||
|
||||
sftpPaneViewModeStore.set(paneId, 'list');
|
||||
assert.equal(sftpPaneViewModeStore.get(paneId), 'list');
|
||||
sftpPaneViewModeStore.clear(paneId);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SftpViewMode } from '../../../domain/sftpTypeahead';
|
||||
|
||||
const paneViewModes = new Map<string, SftpViewMode>();
|
||||
|
||||
export const sftpPaneViewModeStore = {
|
||||
get: (paneId: string): SftpViewMode => paneViewModes.get(paneId) ?? 'list',
|
||||
set: (paneId: string, viewMode: SftpViewMode) => {
|
||||
paneViewModes.set(paneId, viewMode);
|
||||
},
|
||||
clear: (paneId: string) => {
|
||||
paneViewModes.delete(paneId);
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
getSftpCurrentPathMemoryKey,
|
||||
getSftpReopenMemoryKey,
|
||||
resolveSftpAutoConnectPath,
|
||||
resolveSftpOpenLocation,
|
||||
} from "./sftpReopenLocation.ts";
|
||||
|
||||
@@ -120,3 +121,32 @@ test("path changes for non-reusable sessions still use the focused terminal sess
|
||||
"mosh-session-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-connect prefers explicit open path over remembered browse path", () => {
|
||||
assert.equal(
|
||||
resolveSftpAutoConnectPath({
|
||||
explicitPath: "/tmp/upload",
|
||||
rememberedPath: "/home/deploy/projects",
|
||||
}),
|
||||
"/tmp/upload",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-connect restores remembered path when nothing explicit is requested", () => {
|
||||
assert.equal(
|
||||
resolveSftpAutoConnectPath({
|
||||
rememberedPath: "/home/deploy/projects/app",
|
||||
}),
|
||||
"/home/deploy/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-connect ignores empty remembered paths", () => {
|
||||
assert.equal(
|
||||
resolveSftpAutoConnectPath({
|
||||
explicitPath: "",
|
||||
rememberedPath: "",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -48,3 +48,19 @@ export function resolveSftpOpenLocation(params: {
|
||||
|
||||
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path used when the side panel auto-connects / rebinds (not a user open).
|
||||
* Prefer an explicit open target, then the last browsed path for this endpoint.
|
||||
* Terminal cwd is intentionally omitted here so "follow off" stays sticky;
|
||||
* follow mode navigates after connect via the follow sync effect.
|
||||
*/
|
||||
export function resolveSftpAutoConnectPath(params: {
|
||||
explicitPath?: string | null;
|
||||
rememberedPath?: string | null;
|
||||
}): string | undefined {
|
||||
const explicit = params.explicitPath?.length ? params.explicitPath : undefined;
|
||||
if (explicit) return explicit;
|
||||
const remembered = params.rememberedPath?.length ? params.rememberedPath : undefined;
|
||||
return remembered;
|
||||
}
|
||||
@@ -23,8 +23,10 @@ export const buildCacheKey = (
|
||||
protocol?: string,
|
||||
sftpSudo?: boolean,
|
||||
username?: string,
|
||||
sftpFileProtocol?: string,
|
||||
): string => {
|
||||
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
|
||||
const fileProto = sftpFileProtocol && sftpFileProtocol !== "auto" ? sftpFileProtocol : "";
|
||||
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}:${fileProto}`;
|
||||
};
|
||||
|
||||
export const getSharedRemoteHostCache = (
|
||||
|
||||
@@ -205,7 +205,43 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Capture path/endpoint before we replace the connection so same-endpoint
|
||||
// auto-reconnect can land back where the user was browsing instead of home.
|
||||
// Do not inherit path across endpoints (including same hostId with different
|
||||
// hostname/port/user) if a reconnect flag is still set while switching.
|
||||
const previousConnection = getActivePane(side)?.connection;
|
||||
const previousPath = previousConnection?.currentPath;
|
||||
const previousConnectionKey = !previousConnection
|
||||
? null
|
||||
: previousConnection.isLocal
|
||||
? "local"
|
||||
: (connectionCacheKeyMapRef.current.get(previousConnection.id) ?? null);
|
||||
const targetConnectionKey = host === "local"
|
||||
? "local"
|
||||
: buildCacheKey(
|
||||
host.id,
|
||||
host.hostname,
|
||||
host.port,
|
||||
host.protocol,
|
||||
host.sftpSudo,
|
||||
host.username,
|
||||
host.sftpFileProtocol,
|
||||
);
|
||||
if (
|
||||
reconnectingRef.current[side]
|
||||
&& previousConnectionKey
|
||||
&& previousConnectionKey !== targetConnectionKey
|
||||
) {
|
||||
reconnectingRef.current[side] = false;
|
||||
}
|
||||
const isReconnectAttempt = reconnectingRef.current[side];
|
||||
const sameEndpointReconnect =
|
||||
isReconnectAttempt
|
||||
&& !!previousPath
|
||||
&& previousConnectionKey === targetConnectionKey;
|
||||
const effectiveInitialPath =
|
||||
options?.initialPath
|
||||
?? (sameEndpointReconnect ? previousPath : undefined);
|
||||
|
||||
// Notify caller of the tab ID synchronously, before any async work.
|
||||
// This allows callers to map metadata (e.g. connection keys) to the tab
|
||||
@@ -250,7 +286,7 @@ export const useSftpConnections = ({
|
||||
if (host !== "local") {
|
||||
connectionCacheKeyMapRef.current.set(
|
||||
connectionId,
|
||||
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
|
||||
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +325,7 @@ export const useSftpConnections = ({
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const startPath = options?.initialPath || homeDir;
|
||||
const startPath = effectiveInitialPath || homeDir;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -336,14 +372,14 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol);
|
||||
const sharedHostCacheCandidate = options?.ignoreSharedCache
|
||||
? null
|
||||
: getSharedRemoteHostCache(hostCacheKey);
|
||||
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache: options?.ignoreSharedCache,
|
||||
initialPath: options?.initialPath,
|
||||
initialPath: effectiveInitialPath,
|
||||
sharedHostCacheCandidate,
|
||||
});
|
||||
|
||||
@@ -515,7 +551,7 @@ export const useSftpConnections = ({
|
||||
|
||||
if (bridge?.getSftpHomeDir) {
|
||||
try {
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
const result = await bridge.getSftpHomeDir(sftpId, host.sftpEncoding);
|
||||
if (result?.success && result.homeDir) {
|
||||
startPath = result.homeDir;
|
||||
homeDir = result.homeDir;
|
||||
@@ -592,8 +628,9 @@ export const useSftpConnections = ({
|
||||
dirCacheRef.current.delete(provisionalCacheKey);
|
||||
}
|
||||
// Fall back to homeDir, then "/", chaining attempts.
|
||||
// Remembered/reconnect paths can be stale even when shared cache is gone.
|
||||
let fallbackSucceeded = false;
|
||||
if (sharedHostCache && startPath !== homeDir) {
|
||||
if (startPath !== homeDir) {
|
||||
try {
|
||||
startPath = homeDir;
|
||||
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { STORAGE_KEY_SFTP_DIRECTORIES_FIRST } from "../../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useSftpDirectoriesFirst = () => {
|
||||
const [directoriesFirst, setDirectoriesFirst] = useState(
|
||||
() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST) ?? true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncDirectoriesFirst = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_DIRECTORIES_FIRST) return;
|
||||
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_DIRECTORIES_FIRST) return;
|
||||
setDirectoriesFirst(
|
||||
localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST) ?? true,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", syncDirectoriesFirst);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncDirectoriesFirst);
|
||||
return () => {
|
||||
window.removeEventListener("storage", syncDirectoriesFirst);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncDirectoriesFirst);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDirectoriesFirst = useCallback(() => {
|
||||
setDirectoriesFirst((current) => {
|
||||
const next = !current;
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { directoriesFirst, toggleDirectoriesFirst };
|
||||
};
|
||||
@@ -30,9 +30,131 @@ test("buildSftpReuseCredentials only needs the live endpoint and sourceSessionId
|
||||
sourceSessionId: "session-live",
|
||||
reuseOnly: true,
|
||||
sudo: false,
|
||||
fileProtocol: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpReuseCredentials forwards host file protocol preference", () => {
|
||||
const credentials = buildSftpReuseCredentials(
|
||||
host({ hostname: "nas.example.com", username: "root", port: 22, sftpFileProtocol: "scp" }),
|
||||
"session-nas",
|
||||
);
|
||||
assert.equal(credentials.fileProtocol, "scp");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
hostname: "jump.example.com",
|
||||
useSshAgent: true,
|
||||
identityFilePaths: ["~/.ssh/jump"],
|
||||
identitiesOnly: true,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identityFilePaths: ["~/.ssh/aws_root"],
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
}),
|
||||
hosts: [jump],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
useSshAgent: credentials.useSshAgent,
|
||||
identityAgent: credentials.identityAgent,
|
||||
identityFilePaths: credentials.identityFilePaths,
|
||||
identitiesOnly: credentials.identitiesOnly,
|
||||
addKeysToAgent: credentials.addKeysToAgent,
|
||||
useKeychain: credentials.useKeychain,
|
||||
},
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identityFilePaths: ["~/.ssh/aws_root"],
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
},
|
||||
);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.useSshAgent, true);
|
||||
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["~/.ssh/jump"]);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.identitiesOnly, true);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards target and jump-host timeouts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
sshTcpConnectTimeoutSeconds: 75,
|
||||
sshAuthReadyTimeoutSeconds: 360,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
sshTcpConnectTimeoutSeconds: 45,
|
||||
sshAuthReadyTimeoutSeconds: 300,
|
||||
}),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.sshTcpConnectTimeoutMs, 45_000);
|
||||
assert.equal(credentials.sshAuthReadyTimeoutMs, 300_000);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.sshTcpConnectTimeoutMs, 75_000);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.sshAuthReadyTimeoutMs, 360_000);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials keeps automatic auth per target and jump host", () => {
|
||||
const jumpHost = host({ id: "jump-1", authMethod: "auto", password: "jump-secret" });
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "auto",
|
||||
password: "target-secret",
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
}),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.authMethod, "auto");
|
||||
assert.equal(credentials.jumpHosts?.[0]?.authMethod, "auto");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials drops stale identity paths for password-only hosts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
authMethod: "password",
|
||||
password: "jump-secret",
|
||||
useSshAgent: true,
|
||||
identityFilePaths: ["~/.ssh/stale-jump"],
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "target-secret",
|
||||
useSshAgent: true,
|
||||
identityFilePaths: ["~/.ssh/stale-target"],
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
}),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.identityFilePaths, undefined);
|
||||
assert.equal(credentials.useSshAgent, false);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.identityFilePaths, undefined);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.useSshAgent, false);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
@@ -450,6 +572,39 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials keeps automatic target discovery available with an unreadable saved password", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.authMethod, "auto");
|
||||
assert.equal(credentials.password, undefined);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials keeps automatic jump discovery available with an unreadable saved password", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.authMethod, "auto");
|
||||
assert.equal(credentials.jumpHosts?.[0]?.password, undefined);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials omits local key file paths for password auth", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
@@ -514,3 +669,47 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
|
||||
/Saved credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials uses the system agent when a synced key cannot be decrypted", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
useSshAgent: true,
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.useSshAgent, true);
|
||||
assert.deepEqual(credentials.agentPublicKeys, ["ssh-ed25519 AAAASELECTED"]);
|
||||
assert.equal(credentials.privateKey, undefined);
|
||||
});
|
||||
|
||||
test("imported IdentityFile paths remain usable when agent login is disabled", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
useSshAgent: false,
|
||||
identityAgent: "none",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.useSshAgent, false);
|
||||
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
findMissingProxyIdentityId,
|
||||
@@ -36,7 +37,7 @@ interface UseSftpHostCredentialsParams {
|
||||
* authenticated. Endpoint fields must match the session for findReusableSession.
|
||||
*/
|
||||
export const buildSftpReuseCredentials = (
|
||||
host: Pick<Host, "hostname" | "username" | "port">,
|
||||
host: Pick<Host, "hostname" | "username" | "port" | "sftpFileProtocol">,
|
||||
sourceSessionId: string,
|
||||
): NetcattySSHOptions => ({
|
||||
hostname: host.hostname,
|
||||
@@ -45,6 +46,7 @@ export const buildSftpReuseCredentials = (
|
||||
sourceSessionId,
|
||||
reuseOnly: true,
|
||||
sudo: false,
|
||||
fileProtocol: host.sftpFileProtocol || "auto",
|
||||
});
|
||||
|
||||
export const buildSftpHostCredentials = ({
|
||||
@@ -104,7 +106,10 @@ export const buildSftpHostCredentials = ({
|
||||
: jumpHost.identityFilePaths,
|
||||
passphrase: jumpAuth.passphrase,
|
||||
});
|
||||
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
|
||||
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey, jumpAuth.authMethod);
|
||||
const hasJumpKeyMaterial = Boolean(
|
||||
jumpAgentAuth.useSshAgent || jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length,
|
||||
);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
@@ -120,15 +125,18 @@ export const buildSftpHostCredentials = ({
|
||||
isEncryptedCredentialPlaceholder(jumpAuth.passphrase);
|
||||
if (
|
||||
(jumpAuth.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpAuth.password) && !jumpPassword) ||
|
||||
(jumpAuth.authMethod !== "password" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
|
||||
(jumpAuth.authMethod !== "password" && jumpAuth.authMethod !== "auto" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
|
||||
) {
|
||||
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
|
||||
}
|
||||
const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings);
|
||||
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost);
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
hostId: jumpHost.id,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
authMethod: jumpAuth.authMethod,
|
||||
password: jumpPassword,
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
@@ -141,8 +149,11 @@ export const buildSftpHostCredentials = ({
|
||||
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
|
||||
: undefined,
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
...jumpAgentAuth,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
@@ -162,23 +173,29 @@ export const buildSftpHostCredentials = ({
|
||||
: host.identityFilePaths,
|
||||
passphrase: resolved.passphrase,
|
||||
});
|
||||
const targetAgentAuth = resolveBridgeSshAgentAuth(host, key, resolved.authMethod);
|
||||
const password = sanitizeCredentialValue(resolved.password);
|
||||
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
|
||||
const hasKeyMaterial = Boolean(
|
||||
targetAgentAuth.useSshAgent || keyAuth.privateKey || keyAuth.identityFilePaths?.length,
|
||||
);
|
||||
const hasUnreadableCredential =
|
||||
isEncryptedCredentialPlaceholder(resolved.password) ||
|
||||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
|
||||
isEncryptedCredentialPlaceholder(resolved.passphrase);
|
||||
if (
|
||||
(resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) ||
|
||||
(resolved.authMethod !== "password" && hasUnreadableCredential && !password && !hasKeyMaterial)
|
||||
(resolved.authMethod !== "password" && resolved.authMethod !== "auto" && hasUnreadableCredential && !password && !hasKeyMaterial)
|
||||
) {
|
||||
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
|
||||
}
|
||||
|
||||
const targetKeepalive = resolveHostKeepalive(host, globalTerminalSettings);
|
||||
const targetConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
hostId: host.id,
|
||||
username: resolved.username,
|
||||
authMethod: resolved.authMethod,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
privateKey: keyAuth.privateKey,
|
||||
@@ -190,9 +207,13 @@ export const buildSftpHostCredentials = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
fileProtocol: host.sftpFileProtocol || "auto",
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
...targetAgentAuth,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: targetConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: targetConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
knownHosts,
|
||||
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
|
||||
@@ -140,12 +140,12 @@ export const useSftpPaneActions = ({
|
||||
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
|
||||
const connHost = lastConnectedHostRef.current[side];
|
||||
if (connHost && connHost !== "local" && connHost.id === hostId) {
|
||||
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
|
||||
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username, connHost.sftpFileProtocol);
|
||||
}
|
||||
// Fall back to vault host
|
||||
const host = hostsRef.current.find(h => h.id === hostId);
|
||||
if (host) {
|
||||
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol);
|
||||
}
|
||||
return hostId;
|
||||
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
|
||||
|
||||
test('a registered terminal session can be reconnected on request', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requestedSessionIds: string[] = [];
|
||||
|
||||
registry.register('session-1', () => requestedSessionIds.push('session-1'));
|
||||
|
||||
assert.equal(registry.request('session-1'), true);
|
||||
assert.deepEqual(requestedSessionIds, ['session-1']);
|
||||
});
|
||||
|
||||
test('requesting a terminal session before its handler mounts reconnects after registration', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
|
||||
assert.equal(registry.request('session-1'), true);
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
});
|
||||
|
||||
test('cleanup only removes the handler that created it', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
const unregisterOldHandler = registry.register('session-1', () => requests.push('old'));
|
||||
|
||||
registry.register('session-1', () => requests.push('current'));
|
||||
unregisterOldHandler();
|
||||
|
||||
assert.equal(registry.request('session-1'), true);
|
||||
assert.deepEqual(requests, ['current']);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
type TerminalReconnectHandler = () => void;
|
||||
|
||||
export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
|
||||
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
|
||||
handlers.set(sessionId, handler);
|
||||
if (pendingRequests.delete(sessionId)) {
|
||||
handler();
|
||||
}
|
||||
return () => {
|
||||
if (handlers.get(sessionId) === handler) {
|
||||
handlers.delete(sessionId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const request = (sessionId: string): boolean => {
|
||||
const handler = handlers.get(sessionId);
|
||||
if (!handler) {
|
||||
pendingRequests.add(sessionId);
|
||||
return true;
|
||||
}
|
||||
handler();
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request };
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -1,9 +1,27 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
reorderToolbarItems,
|
||||
type ToolbarItemLayout,
|
||||
type ToolbarItemLayoutDefaults,
|
||||
type ToolbarItemPlacement,
|
||||
} from '../../domain/toolbarItemLayout';
|
||||
import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = 'sftp' | 'scripts' | 'history' | 'theme' | 'system' | 'notes' | 'ai';
|
||||
export type TerminalSidePanelTabId =
|
||||
| 'sftp'
|
||||
| 'scripts'
|
||||
| 'history'
|
||||
| 'theme'
|
||||
| 'system'
|
||||
| 'notes'
|
||||
| 'ai';
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
@@ -15,27 +33,21 @@ export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'ai',
|
||||
];
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_IDS = new Set<TerminalSidePanelTabId>(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
|
||||
export const TERMINAL_SIDE_PANEL_TAB_IDS = new Set<TerminalSidePanelTabId>(
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
|
||||
);
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
order: [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER],
|
||||
placement: Object.fromEntries(
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.map((id) => [id, 'show' as const]),
|
||||
),
|
||||
};
|
||||
|
||||
/** Order-only normalizer (legacy + tests). */
|
||||
export function normalizeTerminalSidePanelTabOrder(value: unknown): TerminalSidePanelTabId[] {
|
||||
if (!Array.isArray(value) || value.length !== TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.length) {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
}
|
||||
|
||||
const seen = new Set<TerminalSidePanelTabId>();
|
||||
const normalized: TerminalSidePanelTabId[] = [];
|
||||
for (const candidate of value) {
|
||||
if (typeof candidate !== 'string') return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(candidate as TerminalSidePanelTabId)) {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
}
|
||||
const tab = candidate as TerminalSidePanelTabId;
|
||||
if (seen.has(tab)) return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
seen.add(tab);
|
||||
normalized.push(tab);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return normalizeToolbarItemLayout(value, TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS)
|
||||
.order as TerminalSidePanelTabId[];
|
||||
}
|
||||
|
||||
export function reorderTerminalSidePanelTab(
|
||||
@@ -44,50 +56,75 @@ export function reorderTerminalSidePanelTab(
|
||||
targetTab: TerminalSidePanelTabId,
|
||||
placement: 'before' | 'after' = 'before',
|
||||
): TerminalSidePanelTabId[] {
|
||||
if (draggedTab === targetTab) return order;
|
||||
if (!order.includes(draggedTab) || !order.includes(targetTab)) return order;
|
||||
|
||||
const withoutDragged = order.filter((tab) => tab !== draggedTab);
|
||||
const targetIndex = withoutDragged.indexOf(targetTab);
|
||||
if (targetIndex === -1) return order;
|
||||
const insertionIndex = placement === 'after' ? targetIndex + 1 : targetIndex;
|
||||
return [
|
||||
...withoutDragged.slice(0, insertionIndex),
|
||||
draggedTab,
|
||||
...withoutDragged.slice(insertionIndex),
|
||||
];
|
||||
const layout: ToolbarItemLayout = {
|
||||
order,
|
||||
placement: Object.fromEntries(order.map((id) => [id, 'show' as ToolbarItemPlacement])),
|
||||
};
|
||||
return reorderToolbarItems(layout, draggedTab, targetTab, placement)
|
||||
.order as TerminalSidePanelTabId[];
|
||||
}
|
||||
|
||||
function readTerminalSidePanelTabOrder(): TerminalSidePanelTabId[] {
|
||||
/**
|
||||
* Seed the modern layout storage from the legacy order-only key when needed.
|
||||
*/
|
||||
function migrateSidePanelTabLayoutIfNeeded(): void {
|
||||
try {
|
||||
return normalizeTerminalSidePanelTabOrder(
|
||||
localStorageAdapter.read<TerminalSidePanelTabId[]>(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER),
|
||||
);
|
||||
const modern = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT);
|
||||
if (modern != null) return;
|
||||
const legacy = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER);
|
||||
if (legacy == null) return;
|
||||
const layout = normalizeToolbarItemLayout(legacy, TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS);
|
||||
localStorageAdapter.write(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, layout);
|
||||
} catch {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
function persistTerminalSidePanelTabOrder(order: TerminalSidePanelTabId[]): void {
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER, order);
|
||||
} catch {
|
||||
// Best effort only; the toolbar still works with the in-memory order.
|
||||
}
|
||||
}
|
||||
migrateSidePanelTabLayoutIfNeeded();
|
||||
|
||||
export function useTerminalSidePanelTabOrder(): {
|
||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||
layout: ToolbarItemLayout;
|
||||
setPlacement: ReturnType<typeof useToolbarItemLayout>['setPlacement'];
|
||||
move: ReturnType<typeof useToolbarItemLayout>['move'];
|
||||
reorder: (draggedId: string, targetId: string, placement?: 'before' | 'after') => void;
|
||||
resetLayout: () => void;
|
||||
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
|
||||
} {
|
||||
const [sidePanelTabOrder, setSidePanelTabOrderRaw] = useState<TerminalSidePanelTabId[]>(
|
||||
readTerminalSidePanelTabOrder,
|
||||
const {
|
||||
layout,
|
||||
setPlacement,
|
||||
setOrder,
|
||||
move,
|
||||
reorder,
|
||||
reset,
|
||||
partition,
|
||||
} = useToolbarItemLayout(
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
||||
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
|
||||
const setSidePanelTabOrder = useCallback((order: TerminalSidePanelTabId[]) => {
|
||||
setSidePanelTabOrderRaw(order);
|
||||
persistTerminalSidePanelTabOrder(order);
|
||||
}, []);
|
||||
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
|
||||
|
||||
return { sidePanelTabOrder, setSidePanelTabOrder };
|
||||
const setSidePanelTabOrder = useCallback(
|
||||
(order: TerminalSidePanelTabId[]) => {
|
||||
setOrder(order);
|
||||
},
|
||||
[setOrder],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
sidePanelTabOrder,
|
||||
setSidePanelTabOrder,
|
||||
layout,
|
||||
setPlacement,
|
||||
move,
|
||||
reorder,
|
||||
resetLayout: reset,
|
||||
partition,
|
||||
}),
|
||||
[layout, move, partition, reorder, reset, setPlacement, setSidePanelTabOrder, sidePanelTabOrder],
|
||||
);
|
||||
}
|
||||
@@ -49,13 +49,14 @@ import {
|
||||
} from './aiDraftState';
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
|
||||
import {
|
||||
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
bumpDraftMutationVersion,
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupSdkAgentSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
@@ -620,7 +621,7 @@ export function useAIState() {
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupSdkAgentSessions([sessionId]);
|
||||
cleanupDeletedAIChatSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -662,7 +663,7 @@ export function useAIState() {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
||||
.map(s => s.id);
|
||||
cleanupSdkAgentSessions(removedSessionIds);
|
||||
cleanupDeletedAIChatSessions(removedSessionIds);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -765,6 +766,8 @@ export function useAIState() {
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const clearSessionMessages = useCallback((sessionId: string) => {
|
||||
getAgentRuntime().clearChatSession(sessionId);
|
||||
void getAIBridge()?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
|
||||
@@ -164,10 +164,17 @@ export function useAgentDiscovery(
|
||||
const envChanged =
|
||||
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged) {
|
||||
const versionChanged = Boolean(match.version) && ea.cliVersion !== match.version;
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged || versionChanged) {
|
||||
changed = true;
|
||||
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
|
||||
return { ...rest, args: match.args, sdkBackend: backend, ...(env ? { env } : {}) };
|
||||
return {
|
||||
...rest,
|
||||
args: match.args,
|
||||
sdkBackend: backend,
|
||||
...(match.version ? { cliVersion: match.version } : {}),
|
||||
...(env ? { env } : {}),
|
||||
};
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -194,6 +201,7 @@ export function useAgentDiscovery(
|
||||
icon: agent.icon,
|
||||
enabled: true,
|
||||
sdkBackend: backend,
|
||||
...(agent.version ? { cliVersion: agent.version } : {}),
|
||||
...(agent.command === 'claude'
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: agent.command === 'opencode'
|
||||
|
||||
@@ -33,12 +33,16 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const checkSshAgent = useCallback(async (options?: {
|
||||
identityAgent?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
}): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
const status = await bridge?.checkSshAgent?.(options);
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
};
|
||||
|
||||
@@ -5,16 +5,20 @@ import { readFileSync } from "node:fs";
|
||||
test("auto-sync establishes the initial data baseline before debouncing edits", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const baselineCommentIndex = source.indexOf("Establish the initial baseline immediately");
|
||||
const baselineHelperIndex = source.indexOf("const establishInitialBaseline = () =>", baselineCommentIndex);
|
||||
const initializedAssignmentIndex = source.indexOf("isInitializedRef.current = true;", baselineHelperIndex);
|
||||
const hashReadIndex = source.indexOf("const currentHash = await getDataHash();", baselineHelperIndex);
|
||||
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", baselineCommentIndex);
|
||||
const initializedAssignmentIndex = source.indexOf("isInitializedRef.current = true;", initializationGuardIndex);
|
||||
const hashReadIndex = source.indexOf("const currentHash = await getDataHash();", initializationGuardIndex);
|
||||
const baselineCallIndex = source.indexOf("establishInitialBaseline();", initializationGuardIndex);
|
||||
const debounceCommentIndex = source.indexOf("Debounce first, then build the expensive full-data hash", initializationGuardIndex);
|
||||
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", debounceCommentIndex);
|
||||
|
||||
assert.notEqual(baselineCommentIndex, -1);
|
||||
assert.notEqual(baselineHelperIndex, -1);
|
||||
assert.notEqual(initializationGuardIndex, -1);
|
||||
assert.notEqual(initializedAssignmentIndex, -1);
|
||||
assert.notEqual(hashReadIndex, -1);
|
||||
assert.notEqual(baselineCallIndex, -1);
|
||||
assert.notEqual(debounceCommentIndex, -1);
|
||||
assert.notEqual(debounceTimerIndex, -1);
|
||||
assert.ok(
|
||||
@@ -22,11 +26,131 @@ test("auto-sync establishes the initial data baseline before debouncing edits",
|
||||
"initialization must be marked synchronously before reading the baseline hash",
|
||||
);
|
||||
assert.ok(
|
||||
initializationGuardIndex < debounceTimerIndex,
|
||||
baselineCallIndex < debounceTimerIndex,
|
||||
"the first baseline hash must be captured before scheduling the debounced auto-sync timer",
|
||||
);
|
||||
});
|
||||
|
||||
test("paused convergent sync captures a baseline before returning", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const effectIndex = source.indexOf("// Debounced auto-sync when data changes");
|
||||
const pausedGuardIndex = source.indexOf("if (convergentSyncPaused)", effectIndex);
|
||||
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", pausedGuardIndex);
|
||||
const baselineCallIndex = source.indexOf("establishInitialBaseline();", initializationGuardIndex);
|
||||
const pausedReturnIndex = source.indexOf("return () =>", baselineCallIndex);
|
||||
|
||||
assert.notEqual(pausedGuardIndex, -1);
|
||||
assert.notEqual(initializationGuardIndex, -1);
|
||||
assert.notEqual(baselineCallIndex, -1);
|
||||
assert.notEqual(pausedReturnIndex, -1);
|
||||
assert.ok(
|
||||
pausedGuardIndex < initializationGuardIndex
|
||||
&& initializationGuardIndex < baselineCallIndex
|
||||
&& baselineCallIndex < pausedReturnIndex,
|
||||
"paused mode must preserve the pre-edit baseline before suppressing network sync",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unchanged remote check cannot absorb an existing paused-edit baseline", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const baselineSnapshotIndex = source.indexOf("const hadInitialBaseline = isInitializedRef.current");
|
||||
const remoteChangeIndex = source.indexOf("inspectedRemoteChange = true", baselineSnapshotIndex);
|
||||
const guardedUpdateIndex = source.indexOf(
|
||||
"if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange))",
|
||||
remoteChangeIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(baselineSnapshotIndex, -1);
|
||||
assert.notEqual(remoteChangeIndex, -1);
|
||||
assert.notEqual(guardedUpdateIndex, -1);
|
||||
assert.ok(
|
||||
baselineSnapshotIndex < remoteChangeIndex && remoteChangeIndex < guardedUpdateIndex,
|
||||
"remote inspection must preserve an existing baseline unless it reconciled changed cloud data",
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled convergent remote checks use the CRDT runtime before legacy inspection", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const checkIndex = source.indexOf("const checkRemoteVersion = useCallback");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
||||
checkIndex,
|
||||
);
|
||||
const convergentSyncIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
convergentGuardIndex,
|
||||
);
|
||||
const legacyInspectionIndex = source.indexOf(
|
||||
"manager.inspectProviderRemote(connectedProvider)",
|
||||
convergentSyncIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(checkIndex, -1);
|
||||
assert.notEqual(convergentGuardIndex, -1);
|
||||
assert.notEqual(convergentSyncIndex, -1);
|
||||
assert.notEqual(legacyInspectionIndex, -1);
|
||||
assert.ok(
|
||||
convergentGuardIndex < convergentSyncIndex && convergentSyncIndex < legacyInspectionIndex,
|
||||
"v2 checks must return through the CRDT runtime before the legacy snapshot inspector",
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled convergent startup checks preview empty-vault recovery before syncing", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
||||
);
|
||||
const previewIndex = source.indexOf("manager.previewConvergentRecovery()", convergentGuardIndex);
|
||||
const recoveryPromptIndex = source.indexOf(
|
||||
"requestEmptyVaultRecovery(recoveryPayload)",
|
||||
previewIndex,
|
||||
);
|
||||
const cloudWinsIndex = source.indexOf(
|
||||
"conflictActionOverride: 'download-remote'",
|
||||
recoveryPromptIndex,
|
||||
);
|
||||
const normalSyncIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
cloudWinsIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(convergentGuardIndex, -1);
|
||||
assert.notEqual(previewIndex, -1);
|
||||
assert.notEqual(recoveryPromptIndex, -1);
|
||||
assert.notEqual(cloudWinsIndex, -1);
|
||||
assert.notEqual(normalSyncIndex, -1);
|
||||
assert.ok(
|
||||
convergentGuardIndex < previewIndex
|
||||
&& previewIndex < recoveryPromptIndex
|
||||
&& recoveryPromptIndex < cloudWinsIndex
|
||||
&& cloudWinsIndex < normalSyncIndex,
|
||||
"v2 startup must offer recovery and use cloud-wins before the normal CRDT sync path",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty v2 startup checks allow a validated empty sync to open the gate", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
||||
);
|
||||
const replicaIndex = source.indexOf("manager.loadConvergentReplica()", convergentGuardIndex);
|
||||
const emptyDecisionIndex = source.indexOf("allowEmptyConvergentSync = (", replicaIndex);
|
||||
const syncIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
emptyDecisionIndex,
|
||||
);
|
||||
const guardIndex = source.indexOf(
|
||||
"options?.allowEmptyConvergentSync !== true",
|
||||
source.indexOf("const syncNow = useCallback"),
|
||||
);
|
||||
|
||||
assert.notEqual(replicaIndex, -1);
|
||||
assert.notEqual(emptyDecisionIndex, -1);
|
||||
assert.notEqual(syncIndex, -1);
|
||||
assert.notEqual(guardIndex, -1);
|
||||
assert.ok(replicaIndex < emptyDecisionIndex && emptyDecisionIndex < syncIndex);
|
||||
});
|
||||
|
||||
test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const helperIndex = source.indexOf("const getSyncPayloadDataHash = (payload: SyncPayload): string");
|
||||
|
||||
@@ -17,11 +17,16 @@ import {
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
|
||||
import {
|
||||
resolveCloudSyncConflictAction,
|
||||
type CloudSyncConflictAction,
|
||||
} from '../../domain/syncStrategy';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectCloudSyncableSettings,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getConvergentSyncLocalConfig } from '../../infrastructure/services/convergentSyncConfig';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
@@ -61,6 +67,10 @@ interface AutoSyncConfig {
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
@@ -122,6 +132,9 @@ type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
notifyOnFailure?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
allowEmptyConvergentSync?: boolean;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
@@ -137,7 +150,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const convergentSyncPaused = sync.convergentSyncConfig.initialized
|
||||
&& !sync.convergentSyncConfig.enabled;
|
||||
const { onApplyPayload, onApplyConvergentPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
@@ -241,6 +256,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
|
||||
if (!enabled) return false;
|
||||
// Read through the shared external store at the operation boundary too.
|
||||
// This closes the small window between a Settings toggle and React
|
||||
// committing the subscription update in this hook instance.
|
||||
const currentConvergentConfig = getConvergentSyncLocalConfig();
|
||||
if (currentConvergentConfig.initialized && !currentConvergentConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
const trigger: SyncTrigger = options?.trigger ?? 'auto';
|
||||
|
||||
isSyncRunningRef.current = true;
|
||||
@@ -327,7 +349,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const dataHash = await getDataHash();
|
||||
const payload = await buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
if (
|
||||
encryptedCredentialPaths.length > 0
|
||||
&& options?.conflictActionOverride !== 'download-remote'
|
||||
) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
@@ -343,7 +368,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulCloudSyncData(payload)) {
|
||||
if (
|
||||
!hasMeaningfulCloudSyncData(payload)
|
||||
&& options?.conflictActionOverride !== 'download-remote'
|
||||
&& options?.allowEmptyConvergentSync !== true
|
||||
) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return false;
|
||||
@@ -351,7 +380,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
const results = await sync.syncNow(
|
||||
payload,
|
||||
{
|
||||
...(options?.conflictActionOverride
|
||||
? { conflictActionOverride: options.conflictActionOverride }
|
||||
: {}),
|
||||
applyConvergentPayload: onApplyConvergentPayload,
|
||||
},
|
||||
);
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
// state gets updated even when some providers failed
|
||||
@@ -360,7 +397,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
&& resultList.every((result) => result.success);
|
||||
|
||||
for (const result of resultList) {
|
||||
if (result.mergedPayload) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
@@ -404,15 +441,22 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw error;
|
||||
}
|
||||
console.error('[AutoSync] Sync failed:', error);
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
if (options?.notifyOnFailure !== false) {
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [enabled, sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
}, [enabled, sync, buildPayload, getDataHash, onApplyConvergentPayload, onApplyPayload, t]);
|
||||
|
||||
const syncNowRef = useRef(syncNow);
|
||||
useEffect(() => {
|
||||
syncNowRef.current = syncNow;
|
||||
}, [syncNow]);
|
||||
|
||||
// One-shot toast per mount when a previous apply was interrupted, so the
|
||||
// user understands why auto-sync is silently paused and where to go to
|
||||
@@ -461,7 +505,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}, [getDataHash]);
|
||||
|
||||
const refreshPendingLocalSync = useCallback(async () => {
|
||||
if (!enabled || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
|
||||
if (
|
||||
!enabled
|
||||
|| convergentSyncPaused
|
||||
|| !sync.hasAnyConnectedProvider
|
||||
|| !sync.isUnlocked
|
||||
) {
|
||||
manager.setPendingLocalSync(false);
|
||||
return;
|
||||
}
|
||||
@@ -475,7 +524,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
manager.setPendingLocalSync(hashDecision === 'sync');
|
||||
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
|
||||
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
|
||||
|
||||
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
|
||||
useEffect(() => {
|
||||
@@ -497,6 +546,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const currentConvergentConfig = getConvergentSyncLocalConfig();
|
||||
if (currentConvergentConfig.initialized && !currentConvergentConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -529,9 +582,73 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Track whether the startup path completed in a state where the anchor/base
|
||||
// are consistent with the local vault. Only then should we latch
|
||||
// hasCheckedRemoteRef so that transient failures are retryable.
|
||||
const hadInitialBaseline = isInitializedRef.current;
|
||||
let startupConsistent = false;
|
||||
let markCurrentDataSynced = true;
|
||||
let inspectedRemoteChange = false;
|
||||
const requestEmptyVaultRecovery = async (remotePayload: SyncPayload) => {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
noteCount: remotePayload.notes?.length ?? 0,
|
||||
});
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
return userAction;
|
||||
};
|
||||
try {
|
||||
if (currentConvergentConfig.initialized && currentConvergentConfig.enabled) {
|
||||
// A v2 remote check must join the provider replicas through the CRDT
|
||||
// runtime. Inspecting the materialized v1 snapshot here would discard
|
||||
// retained candidates and could turn the deterministic winner into a
|
||||
// local write that silently resolves a real field conflict.
|
||||
const localPayload = await buildPayloadRef.current();
|
||||
let recoveryPayload: SyncPayload | null = null;
|
||||
if (!hasCloudSyncEntityData(localPayload)) {
|
||||
recoveryPayload = await manager.previewConvergentRecovery();
|
||||
if (recoveryPayload && shouldPromptCloudVaultRecovery(localPayload, recoveryPayload)) {
|
||||
const userAction = await requestEmptyVaultRecovery(recoveryPayload);
|
||||
if (userAction === 'restore') {
|
||||
const restored = await syncNowRef.current({
|
||||
notifyOnFailure,
|
||||
conflictActionOverride: 'download-remote',
|
||||
});
|
||||
if (restored) {
|
||||
notify.success(
|
||||
tRef.current('sync.autoSync.restoredMessage'),
|
||||
tRef.current('sync.autoSync.restoredTitle'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notify.info(
|
||||
tRef.current('sync.autoSync.keptLocalMessage'),
|
||||
tRef.current('sync.autoSync.keptLocalTitle'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let allowEmptyConvergentSync = false;
|
||||
if (!hasMeaningfulCloudSyncData(localPayload)) {
|
||||
const replica = await manager.loadConvergentReplica();
|
||||
const replicaPayload = replica
|
||||
? materializeSyncPayloadFromConvergentState(replica.state, { syncedAt: Date.now() })
|
||||
: null;
|
||||
allowEmptyConvergentSync = (
|
||||
(!recoveryPayload || !hasMeaningfulCloudSyncData(recoveryPayload))
|
||||
&& (!replicaPayload || !hasMeaningfulCloudSyncData(replicaPayload))
|
||||
);
|
||||
}
|
||||
await syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const inspection = await manager.inspectProviderRemote(connectedProvider);
|
||||
@@ -542,6 +659,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
startupConsistent = true;
|
||||
return;
|
||||
}
|
||||
inspectedRemoteChange = true;
|
||||
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
@@ -551,19 +669,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (shouldPromptCloudVaultRecovery(localPayload, remotePayload)) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
noteCount: remotePayload.notes?.length ?? 0,
|
||||
});
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
const userAction = await requestEmptyVaultRecovery(remotePayload);
|
||||
|
||||
if (userAction === 'restore') {
|
||||
// Apply remote FIRST; only commit anchor/base after the UI-side
|
||||
@@ -721,9 +827,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
if (markCurrentDataSynced) {
|
||||
if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange)) {
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
} else {
|
||||
} else if (!markCurrentDataSynced) {
|
||||
lastSyncedDataRef.current = '';
|
||||
}
|
||||
hasCheckedRemoteRef.current = true;
|
||||
@@ -757,6 +863,30 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// Establish the initial baseline immediately. If this were delayed by
|
||||
// the debounce below, an edit made right after startup could become the
|
||||
// baseline and never be pushed. A paused replica still captures this
|
||||
// one-time baseline so edits made while paused remain pending on resume.
|
||||
const establishInitialBaseline = () => {
|
||||
isInitializedRef.current = true;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
})();
|
||||
};
|
||||
|
||||
if (convergentSyncPaused) {
|
||||
if (!isInitializedRef.current) {
|
||||
establishInitialBaseline();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
// Skip if not ready
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
@@ -770,21 +900,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Establish the initial baseline immediately. If this were delayed by
|
||||
// the debounce below, an edit made right after startup could become the
|
||||
// baseline and never be pushed.
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
})();
|
||||
establishInitialBaseline();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -865,6 +986,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
@@ -891,6 +1013,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// outage (network blip, provider rate-limit) self-heal.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (convergentSyncPaused) return;
|
||||
if (
|
||||
!sync.hasAnyConnectedProvider ||
|
||||
!sync.isUnlocked ||
|
||||
@@ -945,10 +1068,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (!enabled) return;
|
||||
const currentConvergentConfig = getConvergentSyncLocalConfig();
|
||||
if (currentConvergentConfig.initialized && !currentConvergentConfig.enabled) return;
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
@@ -984,6 +1109,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (convergentSyncPaused) return;
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -995,6 +1121,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
convergentSyncPaused,
|
||||
enabled,
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type SyncPayload,
|
||||
type SyncResult,
|
||||
type SyncHistoryEntry,
|
||||
type ConvergentFieldConflict,
|
||||
type WebDAVConfig,
|
||||
type S3Config,
|
||||
formatLastSync,
|
||||
@@ -35,11 +36,31 @@ import {
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
import {
|
||||
getConvergentSyncLocalConfig,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
pauseConvergentSync,
|
||||
refreshConvergentSyncLocalConfigSnapshot,
|
||||
setConvergentSyncLocalConfig,
|
||||
subscribeConvergentSyncLocalConfig,
|
||||
type ConvergentSyncLocalConfig,
|
||||
} from '../../infrastructure/services/convergentSyncConfig';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type ConvergentPayloadApplier = (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
interface CloudSyncRunOptions {
|
||||
overrideShrink?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
applyConvergentPayload?: ConvergentPayloadApplier;
|
||||
}
|
||||
|
||||
export interface CloudSyncHook {
|
||||
// State
|
||||
securityState: SecurityState;
|
||||
@@ -59,6 +80,8 @@ export interface CloudSyncHook {
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingLocalSync: boolean;
|
||||
convergentConflicts: ConvergentFieldConflict[];
|
||||
convergentSyncConfig: ConvergentSyncLocalConfig;
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
@@ -97,11 +120,31 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
syncNow: (payload: SyncPayload, opts?: CloudSyncRunOptions) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<RemoteSyncPayload | null>;
|
||||
resolveConvergentConflict: (
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }>;
|
||||
downgradeConvergentSync: (
|
||||
confirmed: boolean,
|
||||
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
@@ -122,6 +165,8 @@ export interface CloudSyncHook {
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
|
||||
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => void;
|
||||
@@ -208,6 +253,11 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const activeOAuthProviderRef = useRef<'google' | 'onedrive' | null>(null);
|
||||
const activeGitHubAuthAbortRef = useRef<AbortController | null>(null);
|
||||
const activeGitHubAuthAttemptIdRef = useRef<number | null>(null);
|
||||
const convergentSyncConfig = useSyncExternalStore(
|
||||
subscribeConvergentSyncLocalConfig,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
@@ -245,6 +295,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
if (!getConvergentSyncLocalConfig().initialized) return;
|
||||
void manager.refreshConvergentConflicts().catch(() => {
|
||||
// A missing/corrupt replica is surfaced by the next explicit sync.
|
||||
});
|
||||
}, [state.securityState]);
|
||||
|
||||
// ========== Computed Values ==========
|
||||
|
||||
@@ -674,12 +732,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
const syncToProviderWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
@@ -713,6 +775,42 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const resolveConvergentConflictWithUnlock = useCallback(async (
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const refreshConvergentSyncConfig = useCallback(() => {
|
||||
return refreshConvergentSyncLocalConfigSnapshot();
|
||||
}, []);
|
||||
|
||||
const setConvergentSyncEnabled = useCallback((enabled: boolean) => {
|
||||
const current = getConvergentSyncLocalConfig();
|
||||
const next = enabled
|
||||
? setConvergentSyncLocalConfig({ enabled: true, initialized: current.initialized })
|
||||
: pauseConvergentSync();
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const downgradeConvergentSyncWithUnlock = useCallback(async (
|
||||
confirmed: boolean,
|
||||
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
|
||||
applyPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return manager.downgradeConvergentSync(confirmed, buildLocalPayload, applyPayload);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
return {
|
||||
// State
|
||||
@@ -733,6 +831,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingLocalSync: state.pendingLocalSync,
|
||||
convergentConflicts: state.convergentConflicts,
|
||||
convergentSyncConfig,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
@@ -765,6 +865,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
commitRemoteInspection: commitRemoteInspectionWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
resolveConvergentConflict: resolveConvergentConflictWithUnlock,
|
||||
downgradeConvergentSync: downgradeConvergentSyncWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
|
||||
@@ -774,6 +876,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
setConvergentSyncEnabled,
|
||||
refreshConvergentSyncConfig,
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => manager.resetLocalVersion(),
|
||||
|
||||
@@ -4,9 +4,43 @@ import {
|
||||
createExternalMcpStartupSyncPlan,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
|
||||
function installMemoryLocalStorage() {
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, 'dispatchEvent');
|
||||
const backing = new Map<string, string>();
|
||||
|
||||
const storage: Storage = {
|
||||
get length() { return backing.size; },
|
||||
clear() { backing.clear(); },
|
||||
getItem(key: string) { return backing.get(key) ?? null; },
|
||||
key(index: number) { return Array.from(backing.keys())[index] ?? null; },
|
||||
removeItem(key: string) { backing.delete(key); },
|
||||
setItem(key: string, value: string) { backing.set(key, value); },
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
|
||||
Object.defineProperty(globalThis, 'dispatchEvent', { value: () => true, configurable: true });
|
||||
|
||||
return () => {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
if (previousDispatchEvent) {
|
||||
Object.defineProperty(globalThis, 'dispatchEvent', previousDispatchEvent);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'dispatchEvent');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('useExternalMcpToggleState helpers', () => {
|
||||
it('normalizes mode and idle timeout', () => {
|
||||
assert.equal(normalizeExternalMcpMode('persistent'), 'persistent');
|
||||
@@ -14,6 +48,8 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
});
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
@@ -27,11 +63,13 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
enabled: true,
|
||||
mode: 'temporary',
|
||||
idleTimeoutMinutes: 15,
|
||||
sessionIdleTimeoutMinutes: 30,
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.equal(plan.storedEnabled, false);
|
||||
assert.equal(plan.shouldPersistStoredEnabled, true);
|
||||
assert.equal(plan.config.idleTimeoutMinutes, 15);
|
||||
assert.equal(plan.config.sessionIdleTimeoutMinutes, 30);
|
||||
});
|
||||
|
||||
it('startup sync plan keeps persistent enabled', () => {
|
||||
@@ -39,9 +77,23 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
enabled: true,
|
||||
mode: 'persistent',
|
||||
idleTimeoutMinutes: 20,
|
||||
sessionIdleTimeoutMinutes: 45,
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, true);
|
||||
assert.equal(plan.storedEnabled, true);
|
||||
assert.equal(plan.shouldPersistStoredEnabled, false);
|
||||
});
|
||||
|
||||
it('focus-on-host-open defaults to true and round-trips through storage', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.equal(readExternalMcpFocusOnHostOpen(), true);
|
||||
writeExternalMcpFocusOnHostOpen(false);
|
||||
assert.equal(readExternalMcpFocusOnHostOpen(), false);
|
||||
writeExternalMcpFocusOnHostOpen(true);
|
||||
assert.equal(readExternalMcpFocusOnHostOpen(), true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -13,10 +15,12 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
@@ -61,6 +65,33 @@ export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
}
|
||||
|
||||
export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN, focusOnHostOpen);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
|
||||
}
|
||||
|
||||
export function normalizeSessionIdleTimeoutMinutes(value: number | null): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
Math.max(
|
||||
MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
Math.round(value ?? DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function readSessionIdleTimeoutMinutes(): number {
|
||||
return normalizeSessionIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -82,10 +113,12 @@ export function createExternalMcpStartupSyncPlan({
|
||||
enabled,
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -93,6 +126,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
config: {
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -105,6 +139,7 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +147,7 @@ export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = ne
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,24 +7,7 @@ export const useKeychainBackend = () => {
|
||||
return bridge?.generateKeyPair?.(options);
|
||||
}, []);
|
||||
|
||||
const execCommand = useCallback(async (options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
// Algorithm settings — let the keychain "export public key" flow honor
|
||||
// the same per-host SSH algorithm config the terminal uses, so a host
|
||||
// that needs the ECDSA skip / legacy mode / advanced overrides works
|
||||
// here too.
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
}) => {
|
||||
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
return bridge.execCommand(options);
|
||||
@@ -32,4 +15,3 @@ export const useKeychainBackend = () => {
|
||||
|
||||
return { generateKeyPair, execCommand };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models.ts";
|
||||
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "Managed host",
|
||||
hostname: "managed.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
test("managed SSH source comparison tracks every agent setting", () => {
|
||||
const changedFields: Array<keyof Host> = [
|
||||
"useSshAgent",
|
||||
"identityAgent",
|
||||
"identitiesOnly",
|
||||
"addKeysToAgent",
|
||||
"useKeychain",
|
||||
];
|
||||
|
||||
for (const field of changedFields) {
|
||||
const changed = {
|
||||
...host,
|
||||
[field]: field === "identityAgent" || field === "addKeysToAgent"
|
||||
? "changed"
|
||||
: true,
|
||||
};
|
||||
assert.equal(haveSameManagedSshAgentFields(host, changed), false, field);
|
||||
}
|
||||
|
||||
assert.equal(haveSameManagedSshAgentFields(host, { ...host }), true);
|
||||
});
|
||||
|
||||
test("managed SSH source comparison tracks identity file paths", () => {
|
||||
const withPaths = {
|
||||
...host,
|
||||
identityFilePaths: ["~/.ssh/id_ed25519", "~/.ssh/id_backup"],
|
||||
};
|
||||
|
||||
assert.equal(haveSameManagedSshAgentFields(host, withPaths), false);
|
||||
assert.equal(haveSameManagedSshAgentFields(withPaths, host), false);
|
||||
assert.equal(haveSameManagedSshAgentFields(withPaths, {
|
||||
...withPaths,
|
||||
identityFilePaths: [...withPaths.identityFilePaths],
|
||||
}), true);
|
||||
assert.equal(haveSameManagedSshAgentFields(withPaths, {
|
||||
...withPaths,
|
||||
identityFilePaths: [...withPaths.identityFilePaths].reverse(),
|
||||
}), false);
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { Host, ManagedSource } from "../../domain/models";
|
||||
import {
|
||||
serializeHostsToSshConfig,
|
||||
mergeWithExistingSshConfig,
|
||||
toSafeSshHostAlias,
|
||||
isSafeSshHostMatchLiteral,
|
||||
} from "../../domain/sshConfigSerializer";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
@@ -15,6 +17,18 @@ export interface UseManagedSourceSyncOptions {
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
}
|
||||
|
||||
export const haveSameManagedSshAgentFields = (previous: Host, current: Host): boolean => (
|
||||
previous.useSshAgent === current.useSshAgent
|
||||
&& previous.identityAgent === current.identityAgent
|
||||
&& previous.identitiesOnly === current.identitiesOnly
|
||||
&& previous.addKeysToAgent === current.addKeysToAgent
|
||||
&& previous.useKeychain === current.useKeychain
|
||||
&& (previous.identityFilePaths?.length ?? 0) === (current.identityFilePaths?.length ?? 0)
|
||||
&& (previous.identityFilePaths ?? []).every(
|
||||
(path, index) => path === current.identityFilePaths?.[index],
|
||||
)
|
||||
);
|
||||
|
||||
export const useManagedSourceSync = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
@@ -75,9 +89,11 @@ export const useManagedSourceSync = ({
|
||||
for (const host of managedHosts) {
|
||||
if (!host.protocol || host.protocol === "ssh") {
|
||||
// Add both hostname and sanitized label (alias) for matching
|
||||
managedHostnameSet.add(host.hostname.toLowerCase());
|
||||
if (isSafeSshHostMatchLiteral(host.hostname)) {
|
||||
managedHostnameSet.add(host.hostname.toLowerCase());
|
||||
}
|
||||
if (host.label) {
|
||||
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
|
||||
managedHostnameSet.add(toSafeSshHostAlias(host.label, host.hostname).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +301,7 @@ export const useManagedSourceSync = ({
|
||||
prev.label !== curr.label ||
|
||||
prev.group !== curr.group ||
|
||||
prev.protocol !== curr.protocol ||
|
||||
!haveSameManagedSshAgentFields(prev, curr) ||
|
||||
chainChanged;
|
||||
if (hasChanged) {
|
||||
sourceChanged = true;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getAutoStartRuleBlockReason, isAutoStartProxyReady } from "./usePortForwardingAutoStart.ts";
|
||||
import {
|
||||
getAutoStartRuleBlockReason,
|
||||
isAutoStartProxyReady,
|
||||
isPortForwardingAutoStartEnabled,
|
||||
} from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
@@ -115,3 +119,9 @@ test("getAutoStartRuleBlockReason marks rules without a host", () => {
|
||||
"Rule host is not configured",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnect eligibility follows the current auto-start setting", () => {
|
||||
assert.equal(isPortForwardingAutoStartEnabled([rule({ autoStart: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingAutoStartEnabled([rule({ autoStart: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
|
||||
});
|
||||
@@ -95,6 +95,11 @@ export const getAutoStartRuleBlockReason = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isPortForwardingAutoStartEnabled = (
|
||||
rules: PortForwardingRule[],
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
@@ -272,10 +277,20 @@ export const usePortForwardingAutoStart = ({
|
||||
autoStartExecutedRef.current = true;
|
||||
|
||||
const runAutoStart = async () => {
|
||||
// First sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
// First sync with backend to get any active tunnels and subscribe this
|
||||
// renderer to their later disconnect/error events.
|
||||
await syncWithBackend({
|
||||
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
),
|
||||
onStatusChange: (ruleId, status, error) => {
|
||||
updateStoredRuleStatus(ruleId, status, error);
|
||||
},
|
||||
});
|
||||
|
||||
// Load rules from storage
|
||||
// Re-read after the async sync so another window's delete or auto-start
|
||||
// change cannot launch a stale rule.
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, PortForwardingRule } from "../../domain/models.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
startPortForward,
|
||||
stopAndCleanupRuleAndWait,
|
||||
} from "../../infrastructure/services/portForwardingService.ts";
|
||||
import {
|
||||
createPortForwardingStorageSyncHandlers,
|
||||
havePortForwardingRuntimeStatesChanged,
|
||||
hasPortForwardingRuntimePresenceChanged,
|
||||
normalizeRulesWithConnections,
|
||||
} from "./usePortForwardingState.ts";
|
||||
|
||||
const rule: PortForwardingRule = {
|
||||
id: "startup-rule",
|
||||
label: "Startup tunnel",
|
||||
type: "local",
|
||||
localPort: 18080,
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: 8080,
|
||||
hostId: "host-1",
|
||||
autoStart: true,
|
||||
createdAt: 1,
|
||||
status: "inactive",
|
||||
};
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
|
||||
function installEnvironment() {
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const backing = new Map<string, string>();
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
|
||||
const storage: Storage = {
|
||||
get length() { return backing.size; },
|
||||
clear() { backing.clear(); },
|
||||
getItem(key) { return backing.get(key) ?? null; },
|
||||
key(index) { return Array.from(backing.keys())[index] ?? null; },
|
||||
removeItem(key) { backing.delete(key); },
|
||||
setItem(key, value) { backing.set(key, value); },
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_tunnelId: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
storage,
|
||||
emitStatus(status: PortForwardingRule["status"]) {
|
||||
statusListener?.(status);
|
||||
},
|
||||
restore() {
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("same-window auto-start status writes refresh the visible rule from the live tunnel", async (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(async () => {
|
||||
await stopAndCleanupRuleAndWait(rule.id);
|
||||
env.restore();
|
||||
});
|
||||
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([rule]));
|
||||
await startPortForward(rule, host, [host], [], [], () => undefined, true);
|
||||
env.emitStatus("active");
|
||||
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleAdapterChange({
|
||||
type: "netcatty:local-storage-adapter-changed",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
});
|
||||
|
||||
test("cross-window status writes stay active before this window discovers the tunnel", (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(() => env.restore());
|
||||
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([{
|
||||
...rule,
|
||||
id: "other-window-rule",
|
||||
status: "active",
|
||||
}]));
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleBrowserStorage({
|
||||
type: "storage",
|
||||
key: STORAGE_KEY_PORT_FORWARDING,
|
||||
} as StorageEvent);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
});
|
||||
|
||||
test("cross-window stale status cannot override a known live tunnel", async (t) => {
|
||||
const env = installEnvironment();
|
||||
const liveRule = { ...rule, id: "known-live-rule" };
|
||||
t.after(async () => {
|
||||
await stopAndCleanupRuleAndWait(liveRule.id);
|
||||
env.restore();
|
||||
});
|
||||
|
||||
await startPortForward(liveRule, host, [host], [], [], () => undefined, true);
|
||||
env.emitStatus("active");
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([{
|
||||
...liveRule,
|
||||
status: "inactive",
|
||||
}]));
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleBrowserStorage({
|
||||
type: "storage",
|
||||
key: STORAGE_KEY_PORT_FORWARDING,
|
||||
} as StorageEvent);
|
||||
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
});
|
||||
|
||||
test("same-window synchronization preserves an error without a runtime tunnel", (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(() => env.restore());
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([{
|
||||
...rule,
|
||||
id: "failed-start-rule",
|
||||
status: "error",
|
||||
error: "Host not found",
|
||||
}]));
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleAdapterChange({
|
||||
type: "netcatty:local-storage-adapter-changed",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots[0]?.[0]?.status, "error");
|
||||
assert.equal(snapshots[0]?.[0]?.error, "Host not found");
|
||||
});
|
||||
|
||||
test("heartbeat normalization preserves an error without a runtime tunnel", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "heartbeat-error-rule",
|
||||
status: "error",
|
||||
error: "Authentication failed",
|
||||
}]);
|
||||
|
||||
assert.equal(normalized[0]?.status, "error");
|
||||
assert.equal(normalized[0]?.error, "Authentication failed");
|
||||
});
|
||||
|
||||
test("heartbeat normalization clears an error for a reconciled-away tunnel", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "cleanup-error-rule",
|
||||
status: "error",
|
||||
error: "Failed to stop tunnel",
|
||||
}], new Set(["cleanup-error-rule"]));
|
||||
|
||||
assert.equal(normalized[0]?.status, "inactive");
|
||||
assert.equal(normalized[0]?.error, undefined);
|
||||
});
|
||||
|
||||
test("heartbeat writes only when a visible runtime state changes", () => {
|
||||
const current = [{ ...rule, status: "inactive" as const }];
|
||||
const unchanged = [{ ...rule, status: "inactive" as const }];
|
||||
const repaired = [{ ...rule, status: "active" as const }];
|
||||
|
||||
assert.equal(havePortForwardingRuntimeStatesChanged(current, unchanged), false);
|
||||
assert.equal(havePortForwardingRuntimeStatesChanged(current, repaired), true);
|
||||
});
|
||||
|
||||
test("heartbeat notifies when only runtime tunnel presence changes", () => {
|
||||
assert.equal(hasPortForwardingRuntimePresenceChanged({
|
||||
gone: [],
|
||||
appeared: ["cleanup-error-rule"],
|
||||
}), true);
|
||||
assert.equal(hasPortForwardingRuntimePresenceChanged({
|
||||
gone: [],
|
||||
appeared: [],
|
||||
}), false);
|
||||
});
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
startPortForward,
|
||||
stopAllPortForwards,
|
||||
stopAndCleanupRule,
|
||||
stopAndCleanupRuleAndWait,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
@@ -75,8 +79,10 @@ export interface UsePortForwardingStateResult {
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stopTunnel: (
|
||||
ruleId: string,
|
||||
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stopRuleTunnels: (ruleId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
hasRuntimeTunnel: (ruleId: string) => boolean;
|
||||
|
||||
filteredRules: PortForwardingRule[];
|
||||
selectedRule: PortForwardingRule | undefined;
|
||||
@@ -98,7 +104,10 @@ const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
export const normalizeRulesWithConnections = (
|
||||
rules: PortForwardingRule[],
|
||||
reconciledGoneRuleIds: ReadonlySet<string> = new Set(),
|
||||
): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
@@ -109,6 +118,16 @@ const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForward
|
||||
};
|
||||
}
|
||||
|
||||
if (reconciledGoneRuleIds.has(rule.id)) {
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.status === "error") return rule;
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
@@ -117,6 +136,69 @@ const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForward
|
||||
});
|
||||
};
|
||||
|
||||
export const havePortForwardingRuntimeStatesChanged = (
|
||||
current: PortForwardingRule[],
|
||||
next: PortForwardingRule[],
|
||||
): boolean => {
|
||||
if (current.length !== next.length) return true;
|
||||
return next.some((rule, index) => {
|
||||
const existing = current[index];
|
||||
return existing?.id !== rule.id
|
||||
|| existing.status !== rule.status
|
||||
|| existing.error !== rule.error;
|
||||
});
|
||||
};
|
||||
|
||||
export const hasPortForwardingRuntimePresenceChanged = (reconciliation: {
|
||||
gone: string[];
|
||||
appeared: string[];
|
||||
}): boolean => reconciliation.gone.length > 0 || reconciliation.appeared.length > 0;
|
||||
|
||||
const mergeRulesWithKnownConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (!connection) return rule;
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isPortForwardingStorageEvent = (event: Event): boolean => {
|
||||
const key = event.type === "storage"
|
||||
? (event as StorageEvent).key
|
||||
: (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
return key === STORAGE_KEY_PORT_FORWARDING;
|
||||
};
|
||||
|
||||
export const createPortForwardingStorageSyncHandlers = ({
|
||||
onRules,
|
||||
}: {
|
||||
onRules: (rules: PortForwardingRule[]) => void;
|
||||
}) => {
|
||||
const readStoredRules = (): PortForwardingRule[] | null => {
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
return storedRules && Array.isArray(storedRules) ? storedRules : null;
|
||||
};
|
||||
|
||||
return {
|
||||
handleAdapterChange(event: Event) {
|
||||
if (!isPortForwardingStorageEvent(event)) return;
|
||||
const storedRules = readStoredRules();
|
||||
if (storedRules) onRules(mergeRulesWithKnownConnections(storedRules));
|
||||
},
|
||||
handleBrowserStorage(event: Event) {
|
||||
if (!isPortForwardingStorageEvent(event)) return;
|
||||
const storedRules = readStoredRules();
|
||||
if (storedRules) onRules(mergeRulesWithKnownConnections(storedRules));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
@@ -171,29 +253,35 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for storage events for cross-window sync (main window <-> tray panel)
|
||||
// Listen for both browser storage events (other windows) and adapter
|
||||
// events (this window). Auto-start writes statuses outside this hook, so
|
||||
// relying on the browser event alone leaves the launching window stale.
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// Only handle changes from our specific key
|
||||
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
|
||||
|
||||
// Parse the new value
|
||||
if (e.newValue) {
|
||||
try {
|
||||
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
|
||||
if (Array.isArray(newRules)) {
|
||||
// Update global state without triggering another localStorage write
|
||||
globalRules = normalizeRulesWithConnections(newRules);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
removeEventListener?: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
if (typeof target.addEventListener !== "function") return;
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (newRules) => {
|
||||
globalRules = newRules;
|
||||
notifyListeners();
|
||||
},
|
||||
});
|
||||
|
||||
target.addEventListener(
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
handlers.handleAdapterChange,
|
||||
);
|
||||
target.addEventListener("storage", handlers.handleBrowserStorage);
|
||||
return () => {
|
||||
target.removeEventListener?.(
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
handlers.handleAdapterChange,
|
||||
);
|
||||
target.removeEventListener?.("storage", handlers.handleBrowserStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for cross-window reconnect cancellation events.
|
||||
@@ -224,11 +312,23 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const HEARTBEAT_INTERVAL_MS = 4_000;
|
||||
|
||||
const tick = async () => {
|
||||
const { gone, appeared } = await reconcileWithBackend();
|
||||
if (gone.length === 0 && appeared.length === 0) return;
|
||||
|
||||
// Re-derive statuses from the now-updated activeConnections map
|
||||
setGlobalRules(normalizeRulesWithConnections(globalRules));
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (!reconciliation.snapshotAvailable) return;
|
||||
// Always re-derive the visible state. This also repairs a stale
|
||||
// cross-window storage write when the backend map itself did not change.
|
||||
const normalizedRules = normalizeRulesWithConnections(
|
||||
globalRules,
|
||||
new Set(reconciliation.gone),
|
||||
);
|
||||
if (havePortForwardingRuntimeStatesChanged(globalRules, normalizedRules)) {
|
||||
setGlobalRules(normalizedRules);
|
||||
} else if (hasPortForwardingRuntimePresenceChanged(reconciliation)) {
|
||||
// Runtime presence affects Start/Stop actions even when the visible
|
||||
// status and error already match. Notify React without rewriting the
|
||||
// unchanged persisted configuration.
|
||||
globalRules = normalizedRules;
|
||||
notifyListeners();
|
||||
}
|
||||
};
|
||||
|
||||
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
|
||||
@@ -415,18 +515,23 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const stopTunnel = useCallback(
|
||||
async (
|
||||
ruleId: string,
|
||||
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
) => {
|
||||
// Clear any pending reconnect timer when manually stopping
|
||||
clearReconnectTimer(ruleId);
|
||||
return stopPortForward(ruleId, (status) => {
|
||||
setRuleStatus(ruleId, status);
|
||||
onStatusChange?.(status);
|
||||
return stopPortForward(ruleId, (status, error) => {
|
||||
setRuleStatus(ruleId, status, error);
|
||||
onStatusChange?.(status, error);
|
||||
});
|
||||
},
|
||||
[setRuleStatus],
|
||||
);
|
||||
|
||||
const hasRuntimeTunnel = useCallback((ruleId: string) => {
|
||||
const connection = getActiveConnection(ruleId);
|
||||
return connection !== undefined && connection.status !== "inactive";
|
||||
}, []);
|
||||
|
||||
// Filter and sort rules
|
||||
const filteredRules = useMemo(() => {
|
||||
let result = [...rules];
|
||||
@@ -492,6 +597,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
setRuleStatus,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
stopRuleTunnels: stopAndCleanupRuleAndWait,
|
||||
hasRuntimeTunnel,
|
||||
|
||||
filteredRules,
|
||||
selectedRule,
|
||||
|
||||
@@ -5,6 +5,8 @@ type ManualStartPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
timestampsEnabled?: boolean;
|
||||
initialLine?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createLocalTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
type LocalTerminalOptions,
|
||||
} from './sessionFactories';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
@@ -11,7 +16,6 @@ createWorkspaceFromSessionIds,
|
||||
FocusDirection,
|
||||
getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
@@ -21,7 +25,7 @@ import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } fr
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
closeSessionsState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from './sessionWorkspaceDetach';
|
||||
@@ -45,6 +49,7 @@ import {
|
||||
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
||||
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
||||
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
workspaces: Workspace[],
|
||||
@@ -369,6 +374,9 @@ export const useSessionState = ({
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
cleanupClosedTerminalSessions(
|
||||
sessionsRef.current.filter(session => session.workspaceId === workspaceId).map(session => session.id),
|
||||
);
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
@@ -387,88 +395,28 @@ export const useSessionState = ({
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
// Pre-compute outside the setSessions updater so we don't depend on React
|
||||
// having run the updater by the time we queue the microtask. React 18+ does
|
||||
// not guarantee updater execution timing under concurrent scheduling.
|
||||
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
||||
const workspaceIdToMaybeClose =
|
||||
sessionBeingClosed?.workspaceId &&
|
||||
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
||||
? sessionBeingClosed.workspaceId
|
||||
: undefined;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
const getFallback = () => {
|
||||
if (lastRemainingSessionId) return lastRemainingSessionId;
|
||||
if (fallbackWorkspace) return fallbackWorkspace.id;
|
||||
if (fallbackSolo) return fallbackSolo.id;
|
||||
return 'vault';
|
||||
};
|
||||
|
||||
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
dissolvedWorkspaceId,
|
||||
[lastRemainingSessionId],
|
||||
));
|
||||
}
|
||||
|
||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (currentActiveTabId === sessionId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
||||
setActiveTabId(getFallback());
|
||||
}
|
||||
|
||||
return nextWorkspaces;
|
||||
});
|
||||
|
||||
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
||||
if (targetSession?.workspaceId) {
|
||||
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
||||
if (ws) {
|
||||
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
||||
if (pruned) {
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
// Dissolve: remove workspaceId from the remaining session
|
||||
return prevSessions
|
||||
.filter(s => s.id !== sessionId)
|
||||
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
const closeSessions = useCallback((sessionIds: string[]) => {
|
||||
cleanupClosedTerminalSessions(sessionIds);
|
||||
const result = closeSessionsState({
|
||||
sessions: sessionsRef.current,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionIds,
|
||||
currentActiveTabId: activeTabStore.getActiveTabId(),
|
||||
tabOrder: tabOrderRef.current,
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
setWorkspaces(result.workspaces);
|
||||
setSessions(result.sessions);
|
||||
setTabOrder(result.tabOrder);
|
||||
if (result.activeTabId) {
|
||||
setActiveTabId(result.activeTabId);
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
closeSessions([sessionId]);
|
||||
}, [closeSessions]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -558,29 +506,7 @@ export const useSessionState = ({
|
||||
const newSessions: TerminalSession[] = hosts.map(host => {
|
||||
// Handle serial hosts specially
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
return createHostTerminalSession(crypto.randomUUID(), host);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -640,28 +566,7 @@ export const useSessionState = ({
|
||||
}
|
||||
const host = target.host;
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
return createHostTerminalSession(crypto.randomUUID(), host);
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -701,23 +606,38 @@ export const useSessionState = ({
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const base = prevSessions.find(s => s.id === baseSessionId);
|
||||
const joining = prevSessions.find(s => s.id === joiningSessionId);
|
||||
|
||||
setSessions((prevSessions) => {
|
||||
const base = prevSessions.find((s) => s.id === baseSessionId);
|
||||
const joining = prevSessions.find((s) => s.id === joiningSessionId);
|
||||
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
|
||||
setWorkspaces((prev) => addWorkspaceIfMissing(prev, newWorkspace));
|
||||
// Collapse the two session tab slots into the workspace tab so later
|
||||
// detach/close can replace `ws-*` with session ids again.
|
||||
setTabOrder((prevTabOrder) => {
|
||||
const withoutSessions = prevTabOrder.filter(
|
||||
(id) => id !== baseSessionId && id !== joiningSessionId,
|
||||
);
|
||||
if (withoutSessions.includes(newWorkspace.id)) return withoutSessions;
|
||||
const indexes = [baseSessionId, joiningSessionId]
|
||||
.map((id) => prevTabOrder.indexOf(id))
|
||||
.filter((index) => index >= 0);
|
||||
const insertAt = indexes.length > 0 ? Math.min(...indexes) : withoutSessions.length;
|
||||
const next = [...withoutSessions];
|
||||
next.splice(Math.min(insertAt, next.length), 0, newWorkspace.id);
|
||||
return next;
|
||||
});
|
||||
setActiveTabId(newWorkspace.id);
|
||||
|
||||
return prevSessions.map(s => {
|
||||
|
||||
return prevSessions.map((s) => {
|
||||
if (s.id === baseSessionId || s.id === joiningSessionId) {
|
||||
return { ...s, workspaceId: newWorkspace.id };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
@@ -1139,40 +1059,41 @@ export const useSessionState = ({
|
||||
additionalTabIds?: readonly string[];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: prevSessions,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.changed) return prevSessions;
|
||||
setWorkspaces(result.workspaces);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
|
||||
...result.workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
return result.sessions;
|
||||
// Detach from latest refs so continuous-render / memoized panes cannot
|
||||
// act on a stale workspace snapshot and drop the terminal instead of
|
||||
// restoring it as a standalone tab.
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: sessionsRef.current,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
setWorkspaces(result.workspaces);
|
||||
setSessions(result.sessions);
|
||||
setTabOrder((prevTabOrder) => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
|
||||
...result.workspaces.map((w) => w.id),
|
||||
...logViews.map((lv) => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
}, [logViews, setActiveTabId]);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
@@ -1216,6 +1137,7 @@ export const useSessionState = ({
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
@@ -100,6 +101,7 @@ import {
|
||||
DEFAULT_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_HOST_CLICK_BEHAVIOR,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
@@ -122,7 +124,9 @@ import {
|
||||
migrateIncomingTerminalFontId,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
} from './settingsStateDefaults';
|
||||
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
@@ -145,6 +149,12 @@ import {
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceSyncState,
|
||||
type AppearanceRenderSnapshot,
|
||||
type AppearanceSyncEvent,
|
||||
} from './appearanceSync';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
@@ -264,6 +274,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
|
||||
});
|
||||
const [hostClickBehavior, setHostClickBehaviorState] = useState<HostClickBehavior>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
|
||||
return isHostClickBehavior(stored) ? stored : DEFAULT_HOST_CLICK_BEHAVIOR;
|
||||
});
|
||||
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
@@ -442,6 +456,23 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
|
||||
const previousAppearanceRenderRef = useRef<AppearanceRenderSnapshot | null>(null);
|
||||
// Latest appearance kept in a ref so sequential keyed IPC updates (one multi-field
|
||||
// change -> multiple notifies) can compose without waiting for a React re-render.
|
||||
const appearanceStateRef = useRef({
|
||||
theme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
});
|
||||
appearanceStateRef.current = {
|
||||
theme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
};
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
@@ -567,29 +598,40 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
const storedLightId = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
const nextLightId = storedLightId && isValidUiThemeId('light', storedLightId) ? storedLightId : lightUiThemeId;
|
||||
const storedDarkId = readStoredString(STORAGE_KEY_UI_THEME_DARK);
|
||||
const nextDarkId = storedDarkId && isValidUiThemeId('dark', storedDarkId) ? storedDarkId : darkUiThemeId;
|
||||
const storedAccentMode = readStoredString(STORAGE_KEY_ACCENT_MODE);
|
||||
const nextAccentMode = storedAccentMode === 'theme' || storedAccentMode === 'custom' ? storedAccentMode : accentMode;
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
|
||||
const current = appearanceStateRef.current;
|
||||
const nextAppearance = resolveAppearanceSyncState(
|
||||
current,
|
||||
{
|
||||
theme: readStoredString(STORAGE_KEY_THEME),
|
||||
lightUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_LIGHT),
|
||||
darkUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_DARK),
|
||||
accentMode: readStoredString(STORAGE_KEY_ACCENT_MODE),
|
||||
customAccent: readStoredString(STORAGE_KEY_COLOR),
|
||||
},
|
||||
incoming,
|
||||
);
|
||||
const {
|
||||
theme: nextTheme,
|
||||
lightUiThemeId: nextLightId,
|
||||
darkUiThemeId: nextDarkId,
|
||||
accentMode: nextAccentMode,
|
||||
customAccent: nextAccent,
|
||||
} = nextAppearance;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
nextTheme === current.theme &&
|
||||
nextLightId === current.lightUiThemeId &&
|
||||
nextDarkId === current.darkUiThemeId &&
|
||||
nextAccentMode === current.accentMode &&
|
||||
nextAccent === current.customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish synchronously so a later keyed IPC in the same turn composes on top.
|
||||
appearanceStateRef.current = nextAppearance;
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -601,7 +643,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
runThemeTransition(() => {
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
});
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, []);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
|
||||
@@ -683,6 +725,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
|
||||
const storedHostClickBehavior = readStoredString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
|
||||
setHostClickBehaviorState(
|
||||
isHostClickBehavior(storedHostClickBehavior) ? storedHostClickBehavior : DEFAULT_HOST_CLICK_BEHAVIOR,
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
@@ -722,6 +768,20 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const appearanceRender: AppearanceRenderSnapshot = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
};
|
||||
// Capture previous snapshot before overwrite so we can notify only the
|
||||
// appearance fields that actually changed on this render.
|
||||
const previousAppearance = previousAppearanceRenderRef.current;
|
||||
const persistedAppearanceChanged = previousAppearance === null
|
||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||
previousAppearanceRenderRef.current = appearanceRender;
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
@@ -731,6 +791,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
if (!persistedAppearanceChanged && persistMountedRef.current) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -738,9 +799,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
// Emit a keyed IPC notification for each changed appearance field so the
|
||||
// receiver can apply the payload value even if shared storage is still stale.
|
||||
// resolveAppearanceSyncState only trusts the announced key's value; other
|
||||
// fields fall back to storage, so every changed field must be announced.
|
||||
if (!previousAppearance || previousAppearance.theme !== theme) {
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.lightUiThemeId !== lightUiThemeId) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.darkUiThemeId !== darkUiThemeId) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -843,7 +920,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -852,7 +929,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -945,6 +1022,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setHostClickBehavior = useCallback((behavior: HostClickBehavior) => {
|
||||
setHostClickBehaviorState(behavior);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOST_CLICK_BEHAVIOR, behavior);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HOST_CLICK_BEHAVIOR, behavior);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
|
||||
setShowOnlyUngroupedHostsInRootState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
@@ -1374,6 +1458,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
hostClickBehavior,
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
@@ -1444,7 +1530,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -146,6 +146,11 @@ export const useSftpState = (
|
||||
// share the same hostId with different session-time overrides.
|
||||
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Full endpoint key captured at connect time (hostId:hostname:port:…).
|
||||
const getConnectionCacheKey = useCallback((connectionId: string) => {
|
||||
return connectionCacheKeyMapRef.current.get(connectionId) ?? null;
|
||||
}, []);
|
||||
|
||||
// Store last connected host info for reconnection
|
||||
const lastConnectedHostRef = useRef<{
|
||||
left: Host | "local" | null;
|
||||
@@ -155,6 +160,15 @@ export const useSftpState = (
|
||||
right: null,
|
||||
});
|
||||
|
||||
// Keep reconnect metadata in sync when auto-connect reuses an existing tab
|
||||
// without calling connect() (selectTab alone does not update this ref).
|
||||
const setLastConnectedHost = useCallback((
|
||||
side: "left" | "right",
|
||||
host: Host | "local" | null,
|
||||
) => {
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
}, []);
|
||||
|
||||
const handleSessionError = useSftpSessionErrors({
|
||||
getActivePane,
|
||||
leftTabsRef,
|
||||
@@ -412,6 +426,8 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
getConnectionCacheKey,
|
||||
setLastConnectedHost,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
@@ -473,6 +489,8 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
getConnectionCacheKey,
|
||||
setLastConnectedHost,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
@@ -548,6 +566,8 @@ export const useSftpState = (
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
getConnectionCacheKey: (...args: Parameters<typeof getConnectionCacheKey>) => methodsRef.current.getConnectionCacheKey(...args),
|
||||
setLastConnectedHost: (...args: Parameters<typeof setLastConnectedHost>) => methodsRef.current.setLastConnectedHost(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
|
||||
@@ -111,9 +111,9 @@ export const useTerminalBackend = () => {
|
||||
bridge?.ackSessionFlow?.(sessionId, bytes);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const closeSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
await bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
||||
@@ -148,6 +148,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onMoshSessionReady?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onTelnetEchoMode = useCallback((sessionId: string, cb: (evt: { sessionId: string; remoteEcho: boolean; localEcho: boolean }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTelnetEchoMode?.(sessionId, cb);
|
||||
@@ -327,59 +332,76 @@ export const useTerminalBackend = () => {
|
||||
// property dep (`}, [terminalBackend.onHostKeyVerification])`) it
|
||||
// couldn't statically prove safe.
|
||||
return useMemo(
|
||||
() => ({
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
setupOsc7Tracking,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
interruptSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
ackSessionFlow,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onTelnetEchoMode,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
}),
|
||||
() => {
|
||||
const api = {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
setupOsc7Tracking,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
interruptSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
ackSessionFlow,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onTelnetEchoMode,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
};
|
||||
// Only surface onMoshSessionReady when the bridge actually implements it.
|
||||
// A always-truthy wrapper would skip the documented no-event fallback and
|
||||
// leave mosh startup/scripts waiting forever on older builds.
|
||||
Object.defineProperty(api, "onMoshSessionReady", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (typeof bridge?.onMoshSessionReady !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return onMoshSessionReady;
|
||||
},
|
||||
});
|
||||
return api;
|
||||
},
|
||||
[
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
@@ -424,6 +446,7 @@ export const useTerminalBackend = () => {
|
||||
onSessionExit,
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onMoshSessionReady,
|
||||
onTelnetEchoMode,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
moveToolbarItem,
|
||||
normalizeToolbarItemLayout,
|
||||
partitionToolbarItems,
|
||||
reorderToolbarItems,
|
||||
resetToolbarItemLayout,
|
||||
setToolbarItemPlacement,
|
||||
type ToolbarItemLayout,
|
||||
type ToolbarItemLayoutDefaults,
|
||||
type ToolbarItemPartition,
|
||||
type ToolbarItemPlacement,
|
||||
} from '../../domain/toolbarItemLayout';
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export type UseToolbarItemLayoutResult = {
|
||||
layout: ToolbarItemLayout;
|
||||
partition: (availableIds?: readonly string[] | ReadonlySet<string>) => ToolbarItemPartition;
|
||||
setPlacement: (
|
||||
id: string,
|
||||
placement: ToolbarItemPlacement,
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
) => ToolbarItemLayout;
|
||||
/** Replace full order while preserving placements (normalized against defaults). */
|
||||
setOrder: (order: string[]) => void;
|
||||
reorder: (draggedId: string, targetId: string, placement?: 'before' | 'after') => void;
|
||||
move: (
|
||||
id: string,
|
||||
direction: 'earlier' | 'later',
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
function readLayout(storageKey: string, defaults: ToolbarItemLayoutDefaults): ToolbarItemLayout {
|
||||
try {
|
||||
return normalizeToolbarItemLayout(localStorageAdapter.read(storageKey), defaults);
|
||||
} catch {
|
||||
return resetToolbarItemLayout(defaults);
|
||||
}
|
||||
}
|
||||
|
||||
function writeLayout(storageKey: string, layout: ToolbarItemLayout): void {
|
||||
try {
|
||||
localStorageAdapter.write(storageKey, layout);
|
||||
} catch {
|
||||
// Best effort; in-memory state still applies for the session.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist show / collapse / hide + order for a dense toolbar region.
|
||||
* `defaults` should be a stable reference (module-level const).
|
||||
*/
|
||||
export function useToolbarItemLayout(
|
||||
storageKey: string,
|
||||
defaults: ToolbarItemLayoutDefaults,
|
||||
): UseToolbarItemLayoutResult {
|
||||
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== storageKey) return;
|
||||
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
|
||||
setLayout(readLayout(storageKey, defaults));
|
||||
};
|
||||
window.addEventListener('storage', sync);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
return () => {
|
||||
window.removeEventListener('storage', sync);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
};
|
||||
}, [defaults, storageKey]);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: ToolbarItemLayout) => {
|
||||
setLayout(next);
|
||||
writeLayout(storageKey, next);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const setPlacement = useCallback(
|
||||
(
|
||||
id: string,
|
||||
placement: ToolbarItemPlacement,
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
): ToolbarItemLayout => {
|
||||
let nextLayout!: ToolbarItemLayout;
|
||||
setLayout((current) => {
|
||||
nextLayout = setToolbarItemPlacement(current, id, placement, defaults, availableIds);
|
||||
writeLayout(storageKey, nextLayout);
|
||||
return nextLayout;
|
||||
});
|
||||
return nextLayout;
|
||||
},
|
||||
[defaults, storageKey],
|
||||
);
|
||||
|
||||
const setOrder = useCallback(
|
||||
(order: string[]) => {
|
||||
setLayout((current) => {
|
||||
const next = normalizeToolbarItemLayout(
|
||||
{ order, placement: current.placement },
|
||||
defaults,
|
||||
);
|
||||
writeLayout(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaults, storageKey],
|
||||
);
|
||||
|
||||
const reorder = useCallback(
|
||||
(draggedId: string, targetId: string, placement: 'before' | 'after' = 'before') => {
|
||||
setLayout((current) => {
|
||||
const next = reorderToolbarItems(current, draggedId, targetId, placement);
|
||||
writeLayout(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
(
|
||||
id: string,
|
||||
direction: 'earlier' | 'later',
|
||||
availableIds?: readonly string[] | ReadonlySet<string> | null,
|
||||
) => {
|
||||
setLayout((current) => {
|
||||
const next = moveToolbarItem(current, id, direction, availableIds);
|
||||
writeLayout(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
commit(resetToolbarItemLayout(defaults));
|
||||
}, [commit, defaults]);
|
||||
|
||||
const partition = useCallback(
|
||||
(availableIds?: readonly string[] | ReadonlySet<string>) =>
|
||||
partitionToolbarItems(layout, availableIds),
|
||||
[layout],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({ layout, partition, setPlacement, setOrder, reorder, move, reset }),
|
||||
[layout, partition, setPlacement, setOrder, reorder, move, reset],
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const useTrayPanelBackend = () => {
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
canStop?: boolean;
|
||||
}>;
|
||||
}) => void,
|
||||
) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { haveSameVaultAgentSnapshot, resolveVaultAgentEffectiveHost } from './useVaultAgentBridge';
|
||||
|
||||
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
|
||||
|
||||
describe('haveSameVaultAgentSnapshot', () => {
|
||||
it('compares every snapshot field by reference', () => {
|
||||
const snapshot: Snapshot = {
|
||||
hosts: [], keys: [], notes: [], snippets: [], customGroups: [], groupConfigs: [],
|
||||
portForwardingRules: [], managedSources: [],
|
||||
};
|
||||
assert.equal(haveSameVaultAgentSnapshot(snapshot, { ...snapshot }), true);
|
||||
for (const key of Object.keys(snapshot) as Array<keyof Snapshot>) {
|
||||
assert.equal(
|
||||
haveSameVaultAgentSnapshot(snapshot, { ...snapshot, [key]: [] }),
|
||||
false,
|
||||
key,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'production', tags: [], os: 'linux',
|
||||
} as const;
|
||||
|
||||
assert.equal(
|
||||
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
|
||||
'telnet',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Host, Identity, PortForwardingRule, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
|
||||
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import {
|
||||
handleVaultAgentOp,
|
||||
registerVaultAgentHandler,
|
||||
setupVaultAgentBridge,
|
||||
type VaultAgentApiDeps,
|
||||
} from '../../infrastructure/ai/vaultAgentBridgeClient';
|
||||
import {
|
||||
clearReferenceKeyPassphrases,
|
||||
rememberKeyPassphrase,
|
||||
removeDefaultKeyPassphraseAliases,
|
||||
} from '../defaultKeyPassphrases';
|
||||
|
||||
export interface UseVaultAgentBridgeInput {
|
||||
hosts: Host[];
|
||||
@@ -13,61 +20,83 @@ export interface UseVaultAgentBridgeInput {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts: KnownHost[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
managedSources: ManagedSource[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
resolveEffectiveHost: (host: Host) => Host;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
updateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
updateManagedSources: (sources: ManagedSource[]) => void;
|
||||
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
notes: VaultNote[];
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
startTunnel: VaultAgentApiDeps['startTunnel'];
|
||||
stopTunnel: VaultAgentApiDeps['stopTunnel'];
|
||||
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
|
||||
openHost?: VaultAgentApiDeps['openHost'];
|
||||
closeSession?: VaultAgentApiDeps['closeSession'];
|
||||
}
|
||||
|
||||
type VaultAgentSnapshot = {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
notes: VaultNote[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
managedSources: ManagedSource[];
|
||||
};
|
||||
|
||||
const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSnapshot => ({
|
||||
hosts: input.hosts,
|
||||
keys: input.keys,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
groupConfigs: input.groupConfigs,
|
||||
portForwardingRules: input.portForwardingRules,
|
||||
managedSources: input.managedSources,
|
||||
});
|
||||
|
||||
export const haveSameVaultAgentSnapshot = (
|
||||
left: VaultAgentSnapshot,
|
||||
right: VaultAgentSnapshot,
|
||||
): boolean => (Object.keys(left) as Array<keyof VaultAgentSnapshot>)
|
||||
.every((key) => left[key] === right[key]);
|
||||
|
||||
export function resolveVaultAgentEffectiveHost(
|
||||
host: Host,
|
||||
groupConfigs: GroupConfig[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
): Host {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds }),
|
||||
{ validProxyProfileIds },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}
|
||||
|
||||
export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
const inputRef = useRef(input);
|
||||
inputRef.current = input;
|
||||
|
||||
const vaultSnapshotRef = useRef<VaultAgentSnapshot>({
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
});
|
||||
const lastSyncedVaultInputRef = useRef({
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
});
|
||||
const selectedSnapshot = selectVaultAgentSnapshot(input);
|
||||
const vaultSnapshotRef = useRef<VaultAgentSnapshot>(selectedSnapshot);
|
||||
const lastSyncedVaultInputRef = useRef<VaultAgentSnapshot>(selectedSnapshot);
|
||||
|
||||
if (
|
||||
input.hosts !== lastSyncedVaultInputRef.current.hosts
|
||||
|| input.notes !== lastSyncedVaultInputRef.current.notes
|
||||
|| input.snippets !== lastSyncedVaultInputRef.current.snippets
|
||||
|| input.customGroups !== lastSyncedVaultInputRef.current.customGroups
|
||||
) {
|
||||
vaultSnapshotRef.current = {
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
};
|
||||
lastSyncedVaultInputRef.current = {
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
};
|
||||
if (!haveSameVaultAgentSnapshot(selectedSnapshot, lastSyncedVaultInputRef.current)) {
|
||||
vaultSnapshotRef.current = selectedSnapshot;
|
||||
lastSyncedVaultInputRef.current = selectedSnapshot;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,12 +106,20 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
getHosts: () => vaultSnapshotRef.current.hosts,
|
||||
getNotes: () => vaultSnapshotRef.current.notes,
|
||||
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
|
||||
getGroupConfigs: () => vaultSnapshotRef.current.groupConfigs,
|
||||
getPortForwardingRules: () => vaultSnapshotRef.current.portForwardingRules,
|
||||
getManagedSources: () => vaultSnapshotRef.current.managedSources,
|
||||
snippets: vaultSnapshotRef.current.snippets,
|
||||
portForwardingRules: current.portForwardingRules,
|
||||
keys: current.keys,
|
||||
keys: vaultSnapshotRef.current.keys,
|
||||
identities: current.identities,
|
||||
knownHosts: current.knownHosts,
|
||||
proxyProfiles: current.proxyProfiles,
|
||||
terminalSettings: current.terminalSettings,
|
||||
resolveEffectiveHost: current.resolveEffectiveHost,
|
||||
resolveEffectiveHost: (host) => resolveVaultAgentEffectiveHost(
|
||||
host,
|
||||
vaultSnapshotRef.current.groupConfigs,
|
||||
current.proxyProfiles,
|
||||
),
|
||||
updateHostNotes: (hostId, notes) => {
|
||||
const nextHosts = vaultSnapshotRef.current.hosts.map((host) => (
|
||||
host.id === hostId ? { ...host, notes } : host
|
||||
@@ -94,10 +131,40 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.customGroups = groups;
|
||||
current.updateCustomGroups(groups);
|
||||
},
|
||||
updateGroupConfigs: (configs) => {
|
||||
vaultSnapshotRef.current.groupConfigs = configs;
|
||||
current.updateGroupConfigs(configs);
|
||||
},
|
||||
updatePortForwardingRules: (rules) => {
|
||||
vaultSnapshotRef.current.portForwardingRules = rules;
|
||||
current.updatePortForwardingRules(rules);
|
||||
},
|
||||
updateManagedSources: (sources) => {
|
||||
vaultSnapshotRef.current.managedSources = sources;
|
||||
current.updateManagedSources(sources);
|
||||
},
|
||||
updateHosts: (hosts) => {
|
||||
vaultSnapshotRef.current.hosts = hosts;
|
||||
current.updateHosts(hosts);
|
||||
},
|
||||
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase,
|
||||
keys: vaultSnapshotRef.current.keys,
|
||||
updateKeys: current.updateKeys,
|
||||
setCurrentKeys: (keys) => {
|
||||
vaultSnapshotRef.current.keys = keys;
|
||||
},
|
||||
}),
|
||||
removeKeyPassphrases: async (keyPaths) => {
|
||||
const aliases = await removeDefaultKeyPassphraseAliases(keyPaths);
|
||||
const currentKeys = vaultSnapshotRef.current.keys;
|
||||
const updatedKeys = clearReferenceKeyPassphrases(currentKeys, aliases);
|
||||
if (updatedKeys !== currentKeys) {
|
||||
vaultSnapshotRef.current.keys = updatedKeys;
|
||||
await current.updateKeys(updatedKeys);
|
||||
}
|
||||
},
|
||||
updateNotes: (notes) => {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
current.updateNotes(notes);
|
||||
@@ -108,8 +175,12 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
stopRuleTunnels: current.stopRuleTunnels,
|
||||
openHost: current.openHost
|
||||
? (hostId) => current.openHost!(hostId)
|
||||
? (host) => current.openHost!(host)
|
||||
: undefined,
|
||||
closeSession: current.closeSession
|
||||
? (sessionId) => current.closeSession!(sessionId)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { importVaultHostsFromText } from "../../domain/vaultImport.ts";
|
||||
import { readVaultImportFile } from "./vaultImportFile.ts";
|
||||
|
||||
const sessionValue = "#109#0%10.0.0.1%22%root";
|
||||
|
||||
test("MobaXterm import decodes legacy GB18030 Chinese text", async () => {
|
||||
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
|
||||
const suffix = new TextEncoder().encode(`=${sessionValue}`);
|
||||
const encodedLabel = new Uint8Array([
|
||||
0xd6, 0xd0, 0xce, 0xc4, 0xb7, 0xfe, 0xce, 0xf1, 0xc6, 0xf7,
|
||||
]);
|
||||
const bytes = new Uint8Array(prefix.length + encodedLabel.length + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set(encodedLabel, prefix.length);
|
||||
bytes.set(suffix, prefix.length + encodedLabel.length);
|
||||
|
||||
const text = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", text);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "中文服务器");
|
||||
});
|
||||
|
||||
test("MobaXterm import keeps unmarked UTF-8 Chinese text", async () => {
|
||||
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n北京上海=${sessionValue}`;
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([text], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "北京上海");
|
||||
});
|
||||
|
||||
test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chinese", async () => {
|
||||
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n¡prod=${sessionValue}`;
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([text], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "¡prod");
|
||||
});
|
||||
|
||||
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
|
||||
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
|
||||
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
|
||||
const bytes = new Uint8Array(prefix.length + 2 + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set([0xc2, 0xa1], prefix.length);
|
||||
bytes.set(suffix, prefix.length + 2);
|
||||
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
|
||||
"gb18030",
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "隆prod");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { VaultImportFormat } from "../../domain/vaultImport";
|
||||
import { readTextFile } from "../../lib/readTextFile";
|
||||
|
||||
export type VaultImportFileEncoding = "auto" | "utf-8" | "gb18030";
|
||||
|
||||
export const readVaultImportFile = (
|
||||
format: VaultImportFormat,
|
||||
file: File,
|
||||
encoding: VaultImportFileEncoding = "auto",
|
||||
): Promise<string> => {
|
||||
if (format !== "mobaxterm") return readTextFile(file);
|
||||
if (encoding !== "auto") return readTextFile(file, { encoding });
|
||||
return readTextFile(file, { fallbackEncoding: "gb18030" });
|
||||
};
|
||||
@@ -38,6 +38,7 @@ function installLocalStorage(): LocalStorageMock {
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
prepareLocalVaultPayloadApply,
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildCloudSyncPayload,
|
||||
@@ -955,6 +956,44 @@ test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", ()
|
||||
assert.equal(termSettings.fallbackFont, "PingFang SC");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes the terminal host information bar preference", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ showHostInfoBar: false }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal(termSettings.showHostInfoBar, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores the terminal host information bar preference", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { showHostInfoBar: false } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.showHostInfoBar, false);
|
||||
assert.equal(parsed.scrollback, 5000);
|
||||
});
|
||||
|
||||
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -1103,3 +1142,113 @@ test("applyLocalVaultPayload restores known hosts from local backups", async ()
|
||||
assert.ok(imported);
|
||||
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload prepares before import and commits after it succeeds", async () => {
|
||||
const calls: string[] = [];
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applyLocalVaultPayload(payload, {
|
||||
importVaultData: () => {
|
||||
calls.push("import");
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async () => {
|
||||
calls.push("prepare");
|
||||
return async () => {
|
||||
calls.push("commit");
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
||||
});
|
||||
|
||||
test("prepareLocalVaultPayloadApply does not import until the prepared callback runs", async () => {
|
||||
const calls: string[] = [];
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
const applyPreparedPayload = await prepareLocalVaultPayloadApply(payload, {
|
||||
importVaultData: () => {
|
||||
calls.push("import");
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async () => {
|
||||
calls.push("prepare");
|
||||
return async () => {
|
||||
calls.push("commit");
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["prepare"]);
|
||||
await applyPreparedPayload();
|
||||
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
||||
let imported = false;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => applyLocalVaultPayload(payload, {
|
||||
importVaultData: () => {
|
||||
imported = true;
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async () => {
|
||||
throw new Error("replica unavailable");
|
||||
},
|
||||
}),
|
||||
/replica unavailable/,
|
||||
);
|
||||
|
||||
assert.equal(imported, false);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload does not commit convergent writes when local import fails", async () => {
|
||||
let committed = false;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => applyLocalVaultPayload(payload, {
|
||||
importVaultData: async () => {
|
||||
throw new Error("local import failed");
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async () => async () => {
|
||||
committed = true;
|
||||
},
|
||||
}),
|
||||
/local import failed/,
|
||||
);
|
||||
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -203,10 +205,11 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'showHostInfoBar', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
@@ -245,6 +248,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
@@ -455,6 +459,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
|
||||
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
if (showRecent != null) settings.showRecentHosts = showRecent;
|
||||
const hostClickBehavior = localStorageAdapter.readString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
|
||||
if (hostClickBehavior === 'connect' || hostClickBehavior === 'select') {
|
||||
settings.hostClickBehavior = hostClickBehavior;
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
@@ -660,6 +668,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
||||
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
if (settings.hostClickBehavior === 'connect' || settings.hostClickBehavior === 'select') {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOST_CLICK_BEHAVIOR, settings.hostClickBehavior);
|
||||
}
|
||||
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
@@ -937,9 +948,37 @@ export function applySyncPayload(
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export function applyLocalVaultPayload(
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
dependencies: {
|
||||
prepareConvergentRestore?: (
|
||||
payload: SyncPayload,
|
||||
) => Promise<() => Promise<void>>;
|
||||
} = {},
|
||||
): Promise<() => Promise<void>> {
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const commitConvergentRestore = await prepareConvergentRestore(payload);
|
||||
return async () => {
|
||||
await applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyLocalVaultPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
dependencies: {
|
||||
prepareConvergentRestore?: (
|
||||
payload: SyncPayload,
|
||||
) => Promise<() => Promise<void>>;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
const applyPreparedPayload = await prepareLocalVaultPayloadApply(
|
||||
payload,
|
||||
importers,
|
||||
dependencies,
|
||||
);
|
||||
await applyPreparedPayload();
|
||||
}
|
||||
@@ -51,10 +51,16 @@ interface AIChatPanelContentProps {
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleSteer: () => void;
|
||||
handleStop: () => void;
|
||||
canSteer: boolean;
|
||||
isSteering: boolean;
|
||||
steerWarning?: string;
|
||||
lockTurnConfiguration: boolean;
|
||||
canSendCurrentAgent: boolean;
|
||||
providerDisplayName?: string;
|
||||
modelDisplayName?: string;
|
||||
modelCatalogWarning?: string;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
selectedAgentModel: string;
|
||||
handleAgentModelSelect: (modelId: string) => void;
|
||||
@@ -105,10 +111,16 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
canSteer,
|
||||
isSteering,
|
||||
steerWarning,
|
||||
lockTurnConfiguration,
|
||||
canSendCurrentAgent,
|
||||
providerDisplayName,
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
agentModelPresets,
|
||||
selectedAgentModel,
|
||||
handleAgentModelSelect,
|
||||
@@ -256,41 +268,56 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{/* Input area */}
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
<div>
|
||||
{steerWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{steerWarning}
|
||||
</div>
|
||||
) : modelCatalogWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{modelCatalogWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onSteer={handleSteer}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
canSteer={canSteer}
|
||||
isSteering={isSteering}
|
||||
lockTurnConfiguration={lockTurnConfiguration}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
</>
|
||||
|
||||
+154
-78
@@ -13,7 +13,12 @@ import type {
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import {
|
||||
filterAgentModelPresetsForCliVersion,
|
||||
getAgentModelPresets,
|
||||
resolveAgentCliVersion,
|
||||
resolveAgentModelSelection,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
@@ -49,6 +54,7 @@ import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { registerGrantPersister } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from '../infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
@@ -84,6 +90,11 @@ type SdkRuntimeModelTarget = {
|
||||
sdkBackend: string;
|
||||
agentEnv?: Record<string, string>;
|
||||
agentCommand?: string;
|
||||
codexRuntime?: 'sdk' | 'app-server';
|
||||
};
|
||||
type SteerWarning = {
|
||||
reason: 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
|
||||
turnKind?: 'review' | 'compact';
|
||||
};
|
||||
|
||||
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
|
||||
@@ -287,6 +298,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
const [userSkillsStatusVersion, setUserSkillsStatusVersion] = useState(0);
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -301,6 +315,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
abortControllersRef,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
steerExternalAgent,
|
||||
reportStreamError,
|
||||
activeCompaction,
|
||||
} = useAIChatStreaming({
|
||||
@@ -353,6 +368,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
const activeSessionId = normalizedPanelView.mode === 'session' ? normalizedPanelView.sessionId : null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const isSteering = activeSessionId != null && steeringSessionId === activeSessionId;
|
||||
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
|
||||
const inputValue = currentDraft?.text ?? '';
|
||||
const files = currentDraft?.attachments ?? [];
|
||||
@@ -678,24 +694,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
sdkBackend,
|
||||
agentEnv: agent.env,
|
||||
agentCommand: getManualAgentCommand(agent),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildDiscoveredAgentRuntimeModelTarget = useCallback((agent: DiscoveredAgent): SdkRuntimeModelTarget | null => {
|
||||
const sdkBackend = agent.sdkBackend ?? agent.command;
|
||||
if (sdkBackend !== 'opencode') return null;
|
||||
const command = agent.binPath || agent.path || agent.command;
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
return {
|
||||
agentId,
|
||||
cacheKey: buildSdkRuntimeModelCacheKey({
|
||||
id: agentId,
|
||||
command,
|
||||
sdkBackend,
|
||||
env: command ? { OPENCODE_BIN: command } : undefined,
|
||||
}),
|
||||
sdkBackend,
|
||||
agentCommand: command,
|
||||
codexRuntime: sdkBackend === 'codex' ? (agent.codexRuntime ?? 'sdk') : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -745,10 +744,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
`models_${target.agentId}`,
|
||||
target.agentEnv,
|
||||
target.agentCommand,
|
||||
target.codexRuntime,
|
||||
);
|
||||
if (!result?.ok || !Array.isArray(result.models)) {
|
||||
throw new Error(result?.error || 'Failed to load SDK agent models');
|
||||
}
|
||||
setRuntimeModelWarnings((current) => {
|
||||
const next = { ...current };
|
||||
if (result.warning && target.codexRuntime === 'app-server') {
|
||||
next[target.agentId] = t('ai.codex.appServer.modelCatalogWarning');
|
||||
console.warn('[AIChatSidePanel] Codex App Server model catalog unavailable:', result.warning);
|
||||
} else {
|
||||
delete next[target.agentId];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
currentModelId: result.currentModelId ?? null,
|
||||
models: result.models,
|
||||
@@ -756,12 +766,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
},
|
||||
{ force: options.force },
|
||||
).catch((err) => {
|
||||
if (target.codexRuntime === 'app-server') {
|
||||
setRuntimeModelWarnings((current) => ({
|
||||
...current,
|
||||
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
|
||||
}));
|
||||
}
|
||||
if (options.logErrors !== false) {
|
||||
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -776,10 +792,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
applySdkRuntimeModelCatalog(target.agentId, cached);
|
||||
}
|
||||
|
||||
// Respect renderer TTL / in-flight coalescing for all SDK agents including
|
||||
// OpenCode. Forced refresh used to re-spawn opencode on every effect re-run
|
||||
// even when the user never selected OpenCode (#2184). Manual refresh still
|
||||
// passes force via the model selector path.
|
||||
let cancelled = false;
|
||||
void loadSdkRuntimeModelCatalog(target, {
|
||||
force: target.sdkBackend === 'opencode',
|
||||
}).then((catalog) => {
|
||||
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
||||
});
|
||||
@@ -796,52 +814,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const targets = new Map<string, SdkRuntimeModelTarget>();
|
||||
const configuredTargetCacheKeys = new Set<string>();
|
||||
|
||||
for (const agent of externalAgents) {
|
||||
if (!agent.enabled || getExternalAgentSdkBackend(agent) !== 'opencode') continue;
|
||||
const target = buildExternalAgentRuntimeModelTarget(agent);
|
||||
if (target) {
|
||||
targets.set(target.cacheKey, target);
|
||||
configuredTargetCacheKeys.add(target.cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of discoveredAgents) {
|
||||
const target = buildDiscoveredAgentRuntimeModelTarget(agent);
|
||||
if (target) targets.set(target.cacheKey, target);
|
||||
}
|
||||
|
||||
if (targets.size === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
for (const target of targets.values()) {
|
||||
void loadSdkRuntimeModelCatalog(target, { logErrors: false })
|
||||
.then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
if (configuredTargetCacheKeys.has(target.cacheKey)) {
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isVisible,
|
||||
externalAgents,
|
||||
discoveredAgents,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
buildDiscoveredAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog,
|
||||
applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
@@ -854,8 +829,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
if (runtimePresets) return runtimePresets;
|
||||
const presets = getAgentModelPresets(currentAgentConfig?.command);
|
||||
// BYO Codex CLI: hide GPT-5.6 when CLI < 0.144.0 (stored probe or discovery).
|
||||
const cliVersion = resolveAgentCliVersion(currentAgentConfig, discoveredAgents);
|
||||
return filterAgentModelPresetsForCliVersion(presets, cliVersion);
|
||||
}, [
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
runtimeAgentModelPresets,
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
discoveredAgents,
|
||||
]);
|
||||
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
@@ -863,11 +849,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return stored;
|
||||
}
|
||||
if (agentModelPresets.length > 0) {
|
||||
const first = agentModelPresets[0];
|
||||
if (first.thinkingLevels?.length) {
|
||||
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
|
||||
}
|
||||
return first.id;
|
||||
// Use catalog defaultThinkingLevel — do not pick last array entry
|
||||
// (that made GPT-5.6 Sol default to ultra).
|
||||
return resolveAgentModelSelection(agentModelPresets[0]);
|
||||
}
|
||||
return undefined;
|
||||
}, [currentAgentConfig, currentAgentId, agentModelMap, agentModelPresets]);
|
||||
@@ -1072,7 +1056,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
@@ -1082,6 +1066,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
permissionMode: globalPermissionMode,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
@@ -1130,6 +1115,57 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
]);
|
||||
|
||||
const handleSteer = useCallback(async () => {
|
||||
const sessionId = activeSessionRef.current?.id;
|
||||
const draft = currentDraftRef.current;
|
||||
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
|
||||
|
||||
const trimmed = draft.text.trim();
|
||||
const attachments = draft.attachments.map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if (!trimmed && !hasTerminalSelectionAttachments) return;
|
||||
|
||||
const userMessageId = generateId();
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
setSteerWarnings(current => {
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
return next;
|
||||
});
|
||||
setSteeringSessionId(sessionId);
|
||||
try {
|
||||
const result = await steerExternalAgent({
|
||||
chatSessionId: sessionId,
|
||||
userMessageId,
|
||||
userText: trimmed,
|
||||
prompt: modelPrompt,
|
||||
attachments,
|
||||
attachedImages: modelAttachments,
|
||||
});
|
||||
if (result.status === 'accepted') {
|
||||
if (currentDraftRef.current === draft) clearScopeDraft();
|
||||
return;
|
||||
}
|
||||
if (result.status !== 'cancelled') {
|
||||
setSteerWarnings(current => ({
|
||||
...current,
|
||||
[sessionId]: { reason: result.status, turnKind: result.turnKind },
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setSteeringSessionId(current => current === sessionId ? null : current);
|
||||
}
|
||||
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
|
||||
const stopStreamingForSession = useCallback(async (sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
setStreamingForScope(sessionId, false);
|
||||
@@ -1156,11 +1192,46 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return registerGrantPersister((rule) => { addGrant(rule); });
|
||||
}, [addGrant]);
|
||||
|
||||
useEffect(() => setupCodexAppServerInteractionBridge(), []);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
||||
setSteerWarnings(current => {
|
||||
const next = { ...current };
|
||||
delete next[activeSessionId];
|
||||
return next;
|
||||
});
|
||||
stopStreamingForSession(activeSessionId);
|
||||
}, [activeSessionId, stopStreamingForSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || isStreaming) return;
|
||||
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
||||
setSteerWarnings(current => {
|
||||
if (!current[activeSessionId]) return current;
|
||||
const next = { ...current };
|
||||
delete next[activeSessionId];
|
||||
return next;
|
||||
});
|
||||
}, [activeSessionId, isStreaming]);
|
||||
|
||||
const steerWarning = useMemo(() => {
|
||||
if (!activeSessionId) return undefined;
|
||||
const warning = steerWarnings[activeSessionId];
|
||||
if (!warning) return undefined;
|
||||
if (warning.reason === 'not-steerable' && warning.turnKind === 'review') {
|
||||
return t('ai.codex.steer.notSteerableReview');
|
||||
}
|
||||
if (warning.reason === 'not-steerable' && warning.turnKind === 'compact') {
|
||||
return t('ai.codex.steer.notSteerableCompact');
|
||||
}
|
||||
if (warning.reason === 'busy') return t('ai.codex.steer.busy');
|
||||
if (warning.reason === 'inactive') return t('ai.codex.steer.inactive');
|
||||
if (warning.reason === 'unsupported') return t('ai.codex.steer.unsupported');
|
||||
return t('ai.codex.steer.failed');
|
||||
}, [activeSessionId, steerWarnings, t]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
applyHistorySessionSelection(sessionId, {
|
||||
@@ -1193,7 +1264,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
|
||||
deleteSession(sessionId, scopeKey);
|
||||
getAgentRuntime().clearChatSession(sessionId);
|
||||
|
||||
if (deletingActiveSession || deletingLastScopedSession) {
|
||||
setShowHistory(false);
|
||||
@@ -1258,10 +1328,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleSteer={handleSteer}
|
||||
handleStop={handleStop}
|
||||
canSteer={canSteerCurrentTurn}
|
||||
isSteering={isSteering}
|
||||
steerWarning={steerWarning}
|
||||
lockTurnConfiguration={Boolean(isStreaming && isCodexAppServer)}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
createSdkRuntimeModelCache,
|
||||
modelPresetsContainId,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
@@ -36,9 +37,26 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
|
||||
test('Codex App Server model discovery uses a separate cache identity', () => {
|
||||
const sdk = buildSdkRuntimeModelCacheKey({
|
||||
id: 'discovered_codex',
|
||||
command: '/bin/codex',
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'sdk',
|
||||
});
|
||||
const appServer = buildSdkRuntimeModelCacheKey({
|
||||
id: 'discovered_codex',
|
||||
command: '/bin/codex',
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'app-server',
|
||||
});
|
||||
assert.notEqual(sdk, appServer);
|
||||
});
|
||||
|
||||
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', undefined, []), true);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'openai/gpt-5.1', []), true);
|
||||
|
||||
Loaded 100 of 781 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user